diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 0f41f1d77..8621214e8 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -1355,3 +1355,288 @@ fn locked_bedit_discard_preserves_block_epoch_and_render_caches() { let _ = app.update(Message::BlockEditChangesDiscard); assert_eq!(app.tabs[tab].scene.block_epoch, epoch_before); } + +fn placed_visibility(app: &OpenCADStudio) -> &codec::objects::BlockVisibilityParameter { + let tab = &app.tabs[app.active_tab]; + let definition = tab + .scene + .document + .block_records + .get("EDIT_ME") + .unwrap() + .handle; + crate::scene::block_visibility::parameter(&tab.scene.document, definition) + .expect("visibility parameter") +} + +#[test] +fn visibility_parameter_placement_can_cancel_without_document_or_history_changes() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + let history = app.tabs[i].history.undo_stack.len(); + let _ = app.dispatch_command("BPARAMETER"); + feed(&mut app, "Visibility"); + assert_eq!( + app.tabs[i].active_cmd.as_ref().unwrap().name(), + "BPARAMETER" + ); + assert_eq!(app.tabs[i].scene.document, before); + let _ = app.apply_cmd_result(crate::command::CmdResult::Cancel); + assert_eq!(app.tabs[i].scene.document, before); + assert_eq!(app.tabs[i].history.undo_stack.len(), history); + assert!(!app.block_edit_dirty(i)); +} + +#[test] +fn visibility_parameter_placement_has_local_undo_redo_save_and_parent_undo() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + let _ = app.dispatch_command("BPARAMETER VISIBILITY"); + feed(&mut app, "3,4,0"); + assert!(app.tabs[i].active_cmd.is_none()); + assert_eq!( + placed_visibility(&app).def_point, + Vector3::new(3.0, 4.0, 0.0) + ); + assert_eq!(placed_visibility(&app).states[0].name, "VisibilityState0"); + assert_eq!( + members(&app), + before.block_records.get("EDIT_ME").unwrap().entity_handles + ); + assert_eq!(app.tabs[i].history.undo_stack.len(), 1); + assert!(app.block_edit_dirty(i)); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document.objects, before.objects); + assert!(!app.block_edit_dirty(i)); + app.redo_steps(1); + assert_eq!( + placed_visibility(&app).def_point, + Vector3::new(3.0, 4.0, 0.0) + ); + let _ = app.dispatch_command("BSAVE"); + assert!(!app.block_edit_dirty(i)); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert_eq!( + placed_visibility(&app).def_point, + Vector3::new(3.0, 4.0, 0.0) + ); + app.undo_steps(1); + let definition = app.tabs[i] + .scene + .document + .block_records + .get("EDIT_ME") + .unwrap() + .handle; + assert!( + crate::scene::block_visibility::parameter(&app.tabs[i].scene.document, definition) + .is_none() + ); +} + +#[test] +fn visibility_parameter_placement_discard_removes_the_unsaved_graph() { + let (mut app, reference) = fixture(); + let before = app.tabs[app.active_tab].scene.document.clone(); + begin(&mut app, reference); + let _ = app.dispatch_command("BPARAMETER VISIBILITY"); + feed(&mut app, "7,8,0"); + let _ = app.dispatch_command("BEDIT_DISCARD"); + // Closing the editor synchronizes the restored camera to the active VPORT. + let mut after = app.tabs[app.active_tab].scene.document.clone(); + after.vports = before.vports.clone(); + assert_eq!(after, before); +} + +#[test] +fn visibility_parameter_placement_rejects_duplicates_stale_context_and_nonfinite_locations() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let document_id = app.tabs[i].id; + let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let before = app.tabs[i].scene.document.clone(); + for (document_id, location) in [ + (document_id + 1, glam::DVec3::ZERO), + (document_id, glam::DVec3::splat(f64::NAN)), + ] { + let _ = app.handle_place_block_visibility_parameter(document_id, definition, location); + assert_eq!(app.tabs[i].scene.document, before); + assert!(app.tabs[i].history.undo_stack.is_empty()); + } + let _ = app.dispatch_command("BPARAMETER VISIBILITY"); + feed(&mut app, "3,4,0"); + let before = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + let _ = app.dispatch_command("LINE"); + let _ = app.dispatch_command("BPARAMETER VISIBILITY"); + assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE"); + assert_eq!(app.tabs[i].scene.document, before); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + let _ = app.dispatch_command("BTESTBLOCK"); + let test = app.active_tab; + let before = app.tabs[test].scene.document.clone(); + let _ = app.dispatch_command("LINE"); + let _ = app.dispatch_command("BPARAMETER VISIBILITY"); + assert_eq!(app.tabs[test].active_cmd.as_ref().unwrap().name(), "LINE"); + let _ = app.handle_place_block_visibility_parameter(document_id, definition, glam::DVec3::ZERO); + assert_eq!(app.tabs[test].scene.document, before); +} + +#[test] +fn visibility_parameter_placement_preserves_an_existing_authoring_graph() { + use codec::objects::{DynamicBlockObject, DynamicBlockData, BlockEvaluationGraph, ObjectType}; + let (mut app, reference) = fixture(); + let i = app.active_tab; + let doc = &mut app.tabs[i].scene.document; + let definition = doc.block_records.get("EDIT_ME").unwrap().handle; + let dictionary = doc.ensure_extension_dictionary(definition); + let graph_handle = doc.allocate_handle(); + let mut graph = DynamicBlockObject::new("ACAD_EVALUATION_GRAPH", "AcDbEvalGraph"); + graph.handle = graph_handle; + graph.owner = dictionary; + graph.data = DynamicBlockData::EvaluationGraph(BlockEvaluationGraph::default()); + doc.objects + .insert(graph_handle, ObjectType::DynamicBlock(graph)); + if let Some(ObjectType::Dictionary(dictionary)) = doc.objects.get_mut(&dictionary) { + dictionary.add_entry("ACAD_ENHANCEDBLOCK", graph_handle); + } + begin(&mut app, reference); + let before = app.tabs[i].scene.document.clone(); + for command in [ + "BPARAMETER VISIBILITY", + "BVSTATE", + "_BVSTATE\u{1f}NEW\u{1f}Other", + ] { + let _ = app.dispatch_command(command); + assert_eq!(app.tabs[i].scene.document, before); + assert!(app.tabs[i].history.undo_stack.is_empty()); + } + assert!(crate::scene::block_visibility::create( + &mut app.tabs[i].scene.document, + definition, + Vector3::ZERO + ) + .is_none()); + assert_eq!(app.tabs[i].scene.document, before); +} + +#[test] +fn visibility_parameter_placement_roundtrips_its_location_and_reference_grip_in_dwg_and_dxf() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let _ = app.dispatch_command("BPARAMETER VISIBILITY"); + feed(&mut app, "3,4,0"); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 1,2,0"}"#)["ok"], + true + ); + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BEDIT_DISCARD"); + let doc = &app.tabs[app.active_tab].scene.document; + for extension in ["dwg", "dxf"] { + let bytes = crate::io::save_to_bytes(doc, extension, doc.version).unwrap(); + let read = crate::io::load_bytes(&format!("visibility.{extension}"), bytes).unwrap(); + let (definition, parameter) = read + .dynamic_visibility_for_insert(reference) + .expect(extension); + assert_eq!( + read.block_records.get("EDIT_ME").unwrap().handle, + definition + ); + assert_eq!(parameter.def_point, Vector3::new(2.0, 2.0, 0.0)); + assert_eq!(parameter.states[0].name, "VisibilityState0"); + let grips = read + .objects + .values() + .filter_map(|object| match object { + codec::objects::ObjectType::DynamicBlock(object) + if object.owner == parameter.owner => + { + match &object.data { + codec::objects::DynamicBlockData::VisibilityGrip(grip) => { + Some(grip.location) + } + _ => None, + } + } + _ => None, + }) + .collect::>(); + assert_eq!(grips, [Vector3::new(2.0, 2.0, 0.0)], "{extension}"); + } +} + +#[test] +fn visibility_parameter_placement_follows_the_editor_coordinate_frame() { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 100,200,300"}"#)["ok"], + true + ); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"UCS Z 90"}"#)["ok"], + true + ); + // BEDIT bakes UCS changes into the definition and returns to local WCS. + assert!(app.tabs[app.active_tab].active_ucs.is_none()); + let _ = app.dispatch_command("BPARAMETER VISIBILITY"); + feed(&mut app, "3,4,0"); + assert_eq!( + placed_visibility(&app).def_point, + Vector3::new(3.0, 4.0, 0.0) + ); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 1,2,0"}"#)["ok"], + true + ); + assert_eq!( + placed_visibility(&app).def_point, + Vector3::new(2.0, 2.0, 0.0) + ); +} + +#[test] +fn visibility_parameter_placement_reframes_an_empty_definition_and_undoes_it() { + let (mut app, reference) = fixture(); + let i = app.active_tab; + let handles = app.tabs[i] + .scene + .document + .block_records + .get("EDIT_ME") + .unwrap() + .entity_handles + .clone(); + for handle in handles { + app.tabs[i].scene.document.remove_entity(handle); + } + begin(&mut app, reference); + let _ = app.dispatch_command("BPARAMETER VISIBILITY"); + feed(&mut app, "3,4,0"); + let before = app.tabs[i].scene.document.objects.clone(); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 1,2,0"}"#)["ok"], + true + ); + assert_eq!( + placed_visibility(&app).def_point, + Vector3::new(2.0, 2.0, 0.0) + ); + app.undo_steps(1); + assert_eq!( + placed_visibility(&app).def_point, + Vector3::new(3.0, 4.0, 0.0) + ); + assert_eq!(app.tabs[i].scene.document.objects, before); + app.redo_steps(1); + assert_eq!( + placed_visibility(&app).def_point, + Vector3::new(2.0, 2.0, 0.0) + ); +} diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 5396b1868..bc3596097 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -575,6 +575,17 @@ impl OpenCADStudio { return task; } } + CmdResult::PlaceBlockVisibilityParameter { + document_id, + definition, + location, + } => { + return self.handle_place_block_visibility_parameter( + document_id, + definition, + location, + ); + } CmdResult::CreateBlockWithOptions { options } => { if let Some(task) = self.handle_create_block_with_options(options) { return task; diff --git a/src/app/commands/bvisibility.rs b/src/app/commands/bvisibility.rs index 426d64931..441ceeabf 100644 --- a/src/app/commands/bvisibility.rs +++ b/src/app/commands/bvisibility.rs @@ -7,6 +7,60 @@ use codec::{CadDocument, EntityType, Handle}; use iced::Task; impl OpenCADStudio { + pub(in crate::app) fn visibility_placement_error(&self) -> Option<&'static str> { + let tab = &self.tabs[self.active_tab]; + let Some(session) = tab.active_block_edit_session() else { + return Some("Open a block in the Block Editor before placing a parameter."); + }; + visibility::placement_error(&tab.scene.document, session.br_handle) + } + + pub(in crate::app) fn handle_place_block_visibility_parameter( + &mut self, + document_id: u64, + definition: Handle, + location: glam::DVec3, + ) -> Task { + let i = self.active_tab; + if self.tabs[i].id != document_id + || self.tabs[i] + .active_block_edit_session() + .map(|session| session.br_handle) + != Some(definition) + { + self.command_line.push_error( + crate::t!("The parameter's Block Editor session is no longer active.").as_ref(), + ); + return Task::none(); + } + if let Some(error) = self.visibility_placement_error() { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + if !location.is_finite() { + self.command_line + .push_error(crate::t!("Specify a finite parameter location.").as_ref()); + return Task::none(); + } + self.push_undo_snapshot(i, "Visibility parameter"); + let document = &mut self.tabs[i].scene.document; + visibility::create( + document, + definition, + codec::types::Vector3::new(location.x, location.y, location.z), + ) + .expect("validated visibility placement"); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.finish_pending_history(i); + self.refresh_properties(); + self.command_line.push_output( + crate::t!("Visibility parameter placed. Use BVSTATE to manage its states.").as_ref(), + ); + Task::none() + } + /// The block open in the Block Editor, or an error naming `command`. fn visibility_block(&mut self, i: usize, command: &str) -> Option { let block = self.tabs[i] @@ -72,7 +126,45 @@ impl OpenCADStudio { pub(super) fn dispatch_bvisibility(&mut self, cmd: &str, i: usize) -> Option> { use crate::command::CadCommand; use crate::modules::draw::modify::bvstate::{BvStateCommand, SEP}; + if matches!(cmd, "BVSTATE" | "BVHIDE" | "BVSHOW" | "_BVHIDE" | "_BVSHOW") + || cmd.starts_with("_BVSTATE") + { + if let Some(block) = self.tabs[i] + .active_block_edit_session() + .map(|session| session.br_handle) + { + if visibility::parameter(&self.tabs[i].scene.document, block).is_none() { + if let Some(error) = + visibility::placement_error(&self.tabs[i].scene.document, block) + { + self.command_line.push_error(crate::t!(error).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 { + return Some(Task::none()); + }; + let parameter_type = cmd.strip_prefix("BPARAMETER").unwrap().trim(); + if !parameter_type.is_empty() + && !parameter_type.eq_ignore_ascii_case("VISIBILITY") + && !parameter_type.eq_ignore_ascii_case("V") + { + self.command_line.push_error( + crate::t!("Only Visibility parameter placement is currently supported.") + .as_ref(), + ); + return Some(Task::none()); + } + let command = crate::modules::block_editor::visibility_parameter::VisibilityParameterCommand::new(self.tabs[i].id, block, parameter_type.is_empty()); + self.command_line.push_info(&command.prompt()); + self.command_line.set_step_options(command.options()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Some(Task::none()) + } "BVSTATE" => { let block = self.visibility_block(i, "BVSTATE")?; let document = &self.tabs[i].scene.document; diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 007dfe826..274ecad0c 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -173,6 +173,12 @@ impl OpenCADStudio { }; let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); + if cmd == "BPARAMETER" || cmd.starts_with("BPARAMETER ") { + if let Some(error) = self.visibility_placement_error() { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + } if self.tabs[i].block_test.is_some() { let name = cmd.split([' ', ':']).next().unwrap_or(cmd); if matches!( diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index cff677f78..3219dc615 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -988,3 +988,35 @@ fn authoring_snapshot( .matches_image(path) .expect("write palette evidence")); } + +#[test] +fn visibility_parameter_placement_starts_from_the_actual_palette_row() { + with_stack(|| { + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let before = app.tabs[app.active_tab].scene.document.clone(); + let point = authoring_control(&app, "authoring-tool-visibility"); + authoring_click(&mut app, point); + assert_eq!( + app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(), + "BPARAMETER" + ); + assert_eq!(app.tabs[app.active_tab].scene.document, before); + let _ = app.update(Message::CommandInput("0.25,0.25,0".into())); + let _ = app.update(Message::CommandSubmit); + assert!(app + .visibility_placement_error() + .unwrap() + .contains("already")); + let before = app.tabs[app.active_tab].scene.document.clone(); + let _ = app.dispatch_command("LINE"); + let point = authoring_control(&app, "authoring-tool-visibility"); + authoring_click(&mut app, point); + assert_eq!( + app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(), + "LINE" + ); + assert_eq!(app.tabs[app.active_tab].scene.document, before); + authoring_capture(&app, "visibility-parameter-placed"); + }); +} diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index c70612986..1aa05e381 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -406,6 +406,7 @@ impl OpenCADStudio { self.authoring_palette_tab, width, chrome, + self.visibility_placement_error(), ) } crate::ui::dock::PanelId::BlockPalette => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index e9dfd10dc..31c49ee02 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1032,6 +1032,21 @@ bg={bg_ms:.1}ms n={view_count}" .iter() .map(|entry| tab.scene.selected_constraint == Some(entry.id)) .collect(); + let authoring_visibility = tab.active_block_edit_session().and_then(|session| { + let parameter = crate::scene::block_visibility::parameter( + &tab.scene.document, + session.br_handle, + )?; + let location = glam::DVec3::new( + parameter.def_point.x, + parameter.def_point.y, + parameter.def_point.z, + ); + let point = tab.scene.camera.borrow().project(location, vp_bounds)?; + let point = iced::Point::new(vp_bounds.x + point.x, vp_bounds.y + point.y); + (point.x.is_finite() && point.y.is_finite()) + .then(|| (point, parameter.name.clone(), vp_bounds)) + }); crate::ui::overlay::selection_overlay( std::sync::Arc::clone(&tab.scene.selection), snap_info, @@ -1096,6 +1111,7 @@ bg={bg_ms:.1}ms n={view_count}" self.constraint_glyph_tooltip .map(|kind| crate::t!(kind.label()).into_owned()), constraint_cursor_badge, + authoring_visibility, ) }; diff --git a/src/command.rs b/src/command.rs index 28f11183c..262b10211 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1514,6 +1514,12 @@ impl DimensionPreview { /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { + /// Place the visibility parameter in the definition that requested it. + PlaceBlockVisibilityParameter { + document_id: u64, + definition: Handle, + location: DVec3, + }, /// Return the suspended filter after a read-only sample pick or cancellation. QSelectSample { state: Box, diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 2ac305a8b..4a61a26da 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -5,6 +5,8 @@ use crate::modules::parametric::ParametricModule; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; +pub mod visibility_parameter; + pub struct BlockEditorModule; impl CadModule for BlockEditorModule { diff --git a/src/modules/block_editor/visibility_parameter.rs b/src/modules/block_editor/visibility_parameter.rs new file mode 100644 index 000000000..abb162afc --- /dev/null +++ b/src/modules/block_editor/visibility_parameter.rs @@ -0,0 +1,98 @@ +//! Interactive placement of the supported Visibility action parameter. + +use crate::command::{CadCommand, CmdOption, CmdResult}; +use codec::Handle; +use glam::DVec3; + +pub struct VisibilityParameterCommand { + document_id: u64, + definition: Handle, + choosing_type: bool, +} + +impl VisibilityParameterCommand { + pub fn new(document_id: u64, definition: Handle, choosing_type: bool) -> Self { + Self { + document_id, + definition, + choosing_type, + } + } +} + +impl CadCommand for VisibilityParameterCommand { + fn name(&self) -> &'static str { + "BPARAMETER" + } + + fn prompt(&self) -> String { + if self.choosing_type { + crate::t!("BPARAMETER Enter parameter type [Visibility] :").into_owned() + } else { + crate::t!("BPARAMETER Specify visibility parameter location:").into_owned() + } + } + + fn options(&self) -> Vec { + if self.choosing_type { + vec![CmdOption::new(crate::t!("Visibility").as_ref(), "V")] + } else { + vec![] + } + } + + fn wants_text_input(&self) -> bool { + self.choosing_type + } + + fn on_text_input(&mut self, text: &str) -> Option { + if !self.choosing_type { + return None; + } + Some( + match text + .trim() + .trim_start_matches('_') + .to_ascii_uppercase() + .as_str() + { + "" | "V" | "VISIBILITY" => { + self.choosing_type = false; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError( + crate::t!("Only Visibility parameter placement is currently supported.") + .into_owned(), + ), + }, + ) + } + + fn on_point(&mut self, location: DVec3) -> CmdResult { + if self.choosing_type { + return CmdResult::NeedPoint; + } + if !location.is_finite() { + return CmdResult::ReportError( + crate::t!("Specify a finite parameter location.").into_owned(), + ); + } + CmdResult::PlaceBlockVisibilityParameter { + document_id: self.document_id, + definition: self.definition, + location, + } + } + + fn on_enter(&mut self) -> CmdResult { + if self.choosing_type { + self.on_text_input("").unwrap() + } else { + CmdResult::Cancel + } + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["BPARAMETER"] +}); diff --git a/src/scene/block_visibility.rs b/src/scene/block_visibility.rs index df2d37f95..e618c5ab7 100644 --- a/src/scene/block_visibility.rs +++ b/src/scene/block_visibility.rs @@ -234,6 +234,34 @@ fn new_guid() -> String { ) } +/// Why another visibility parameter cannot be placed in this definition. +/// Existing evaluation graphs must be composed, never replaced by a new graph. +pub fn placement_error(document: &CadDocument, definition: Handle) -> Option<&'static str> { + if parameter(document, definition).is_some() { + return Some("This block already has a visibility parameter."); + } + if !document + .block_records + .iter() + .any(|record| record.handle == definition) + { + return Some("The block definition is no longer available."); + } + if let Some(handle) = document.extension_dictionary_handle(definition) { + match document.objects.get(&handle) { + Some(ObjectType::Dictionary(dictionary)) => { + if dictionary.get("ACAD_ENHANCEDBLOCK").is_some() { + return Some( + "Adding Visibility to an existing authoring graph is not available yet.", + ); + } + } + _ => return Some("The block extension dictionary is unavailable."), + } + } + None +} + /// Make block definition `definition` dynamic with a visibility parameter /// whose grip sits at `location`, one state showing every object. Returns /// the parameter, or the one the block has already. @@ -241,6 +269,13 @@ pub fn create(document: &mut CadDocument, definition: Handle, location: Vector3) if let Some(existing) = parameter(document, definition) { return Some(existing.handle); } + if placement_error(document, definition).is_some() + || !location.x.is_finite() + || !location.y.is_finite() + || !location.z.is_finite() + { + return None; + } let name = document .block_records .iter() @@ -400,6 +435,46 @@ pub fn create(document: &mut CadDocument, definition: Handle, location: Vector3) Some(parameter) } +/// Carry the parameter and its grip through a change of definition coordinates. +/// Expressions read UpdatedX/UpdatedY from the parameter; their cached scalar +/// values are not geometry and must not receive the point transform. +pub fn reframe( + document: &mut CadDocument, + definition: Handle, + transform: &codec::types::Transform, +) -> usize { + let Some(mut parameter) = parameter(document, definition).cloned() else { + return 0; + }; + parameter.def_point = transform.apply(parameter.def_point); + let graph = parameter.owner; + let handle = parameter.handle; + match document.objects.get_mut(&handle) { + Some(ObjectType::BlockVisibilityParameter(stored)) => { + stored.def_point = parameter.def_point + } + Some(ObjectType::DynamicBlock(object)) => { + if let DynamicBlockData::VisibilityParameter(stored) = &mut object.data { + stored.def_point = parameter.def_point; + } + } + _ => {} + } + document.block_visibility_params.insert(handle, parameter); + let mut changed = 1; + for object in document.objects.values_mut() { + if let ObjectType::DynamicBlock(object) = object { + if object.owner == graph { + if let DynamicBlockData::VisibilityGrip(grip) = &mut object.data { + grip.location = transform.apply(grip.location); + changed += 1; + } + } + } + } + changed +} + /// The names of the states of `parameter`, in list order. pub fn state_names(document: &CadDocument, parameter: Handle) -> Vec { match document.objects.get(¶meter) { @@ -765,3 +840,97 @@ mod tests { } } } + +#[cfg(test)] +mod placement_fixtures { + use super::*; + use sha2::{Digest, Sha256}; + + #[test] + #[ignore = "requires OCS_DYNAMIC_BLOCK_FIXTURES with local DWG fixtures"] + fn visibility_parameter_placement_fixture_roundtrips() { + let directory = std::env::var_os("OCS_DYNAMIC_BLOCK_FIXTURES").expect("fixture directory"); + let mut paths = std::fs::read_dir(directory) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("dwg")) + }) + .collect::>(); + paths.sort(); + assert!(!paths.is_empty(), "no DWG fixtures found"); + for path in paths { + let original = std::fs::read(&path).unwrap(); + let digest = Sha256::digest(&original); + let filename = path.file_name().unwrap().to_string_lossy(); + let document = crate::io::load_bytes(&filename, original).expect(&filename); + let before = document.clone(); + let mut scene = Scene::new(); + scene.document = document; + let protected = scene + .document + .block_records + .iter() + .map(|record| record.handle) + .filter(|definition| { + parameter(&scene.document, *definition).is_none() + && placement_error(&scene.document, *definition).is_some() + }) + .collect::>(); + for definition in &protected { + assert!(create(&mut scene.document, *definition, Vector3::ZERO).is_none()); + } + assert_eq!( + scene.document, before, + "{filename}: existing graphs remain untouched" + ); + scene.define_block_raw( + "PALETTE_VISIBILITY_TEST", + Vector3::ZERO, + vec![EntityType::Line(codec::entities::Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))], + ); + let definition = scene + .document + .block_records + .get("PALETTE_VISIBILITY_TEST") + .unwrap() + .handle; + let location = Vector3::new(3.25, -4.5, 0.0); + create(&mut scene.document, definition, location).expect(&filename); + for (handle, object) in &before.objects { + assert_eq!( + scene.document.objects.get(handle), + Some(object), + "{filename}: preserved object {handle}" + ); + } + for extension in ["dwg", "dxf"] { + let bytes = + crate::io::save_to_bytes(&scene.document, extension, scene.document.version) + .expect(&filename); + let read = + crate::io::load_bytes(&format!("placed.{extension}"), bytes).expect(&filename); + let owner = read + .block_records + .get("PALETTE_VISIBILITY_TEST") + .unwrap() + .handle; + assert_eq!( + parameter(&read, owner).expect(&filename).def_point, + location, + "{filename}: {extension}" + ); + } + assert_eq!( + Sha256::digest(std::fs::read(&path).unwrap()), + digest, + "{filename}: source checksum" + ); + eprintln!("{filename}: DWG/DXF placement roundtrips; {} existing graphs protected; source unchanged", protected.len()); + } + } +} diff --git a/src/scene/modify.rs b/src/scene/modify.rs index d235520cd..e58e8a401 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -552,10 +552,12 @@ impl Scene { for handle in changed.iter().copied() { let _ = self.sync_displayed_annotation_context(handle); } - if !changed.is_empty() { + let parameter_changes = + super::block_visibility::reframe(&mut self.document, block_record, local_from_old); + if !changed.is_empty() || parameter_changes > 0 { self.rebuild_derived_caches(); } - changed.len() + changed.len() + parameter_changes } /// Give a freshly-cloned entity brand-new handles for every *inline* diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 89a8ff8c6..7f311e5a4 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -977,6 +977,7 @@ pub fn selection_overlay<'a>( constraint_glyph_selected: Arc<[bool]>, constraint_glyph_tooltip: Option, constraint_cursor_badge: Option, + authoring_visibility: Option<(Point, String, iced::Rectangle)>, ) -> Element<'a, Message> { canvas(SelectionCanvas { selection, @@ -1005,6 +1006,7 @@ pub fn selection_overlay<'a>( constraint_glyph_selected, constraint_glyph_tooltip, constraint_cursor_badge, + authoring_visibility, }) .width(Length::Fill) .height(Length::Fill) @@ -1081,6 +1083,7 @@ struct SelectionCanvas { /// Symbol shown beside the cursor while it targets an entity that already /// participates in an enabled geometric constraint. constraint_cursor_badge: Option, + authoring_visibility: Option<(Point, String, iced::Rectangle)>, } /// The move gizmo: an arrow per axis (X red, Y green, Z blue) from the @@ -2303,6 +2306,25 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&b1, stroke.clone()); frame.stroke(&b2, stroke); } + if let Some((point, label, clip)) = &self.authoring_visibility { + frame.with_clip(*clip, |frame| { + let color = theme.palette().primary.base.color; + let triangle = canvas::Path::new(|builder| { + builder.move_to(Point::new(point.x - 5.0, point.y - 3.0)); + builder.line_to(Point::new(point.x + 5.0, point.y - 3.0)); + builder.line_to(Point::new(point.x, point.y + 5.0)); + builder.close(); + }); + frame.fill(&triangle, color); + frame.fill_text(canvas::Text { + content: label.clone(), + position: Point::new(point.x + 10.0, point.y - 9.0), + color, + size: iced::Pixels(13.0), + ..Default::default() + }); + }); + } // Draw + hover + tooltip all read the memoised entries' precomputed // `top_left`/`size` (one shared `hovered` lookup below) — no offset // recompute, no label clones on this path. diff --git a/src/ui/window/authoring_palettes.rs b/src/ui/window/authoring_palettes.rs index 677ea0e58..461043c40 100644 --- a/src/ui/window/authoring_palettes.rs +++ b/src/ui/window/authoring_palettes.rs @@ -188,7 +188,7 @@ const PARAMETER_SETS: &[(&str, &[u8])] = &[ ]; /// Catalog rows remain inert until their authoring commands exist. -fn catalog(tab: Tab) -> Element<'static, Message> { +fn catalog(tab: Tab, visibility_error: Option<&'static str>) -> Element<'static, Message> { let (entries, unavailable) = match tab { Tab::Parameters => ( PARAMETERS, @@ -202,7 +202,13 @@ fn catalog(tab: Tab) -> Element<'static, Message> { Tab::Constraints => return constraints(), }; column(entries.iter().map(|(label, icon)| { - let base = icons::semantic_disabled(icon, 28.0); + let visibility = tab == Tab::Parameters && *label == "Visibility"; + let enabled = visibility && visibility_error.is_none(); + let base = if enabled { + icons::semantic(icon, 28.0) + } else { + icons::semantic_disabled(icon, 28.0) + }; let icon = if tab != Tab::Parameters && *label != "Block Properties Table" { stack![ base, @@ -219,7 +225,23 @@ fn catalog(tab: Tab) -> Element<'static, Message> { } else { base }; - tool_row(label, icon, None, &unavailable) + let message = enabled.then(|| Message::RibbonToolClick { + tool_id: "BPARAMETER_VISIBILITY".into(), + event: crate::modules::ModuleEvent::Command("BPARAMETER VISIBILITY".into()), + }); + let description = if visibility { + crate::t!(visibility_error + .unwrap_or("Place a visibility parameter, then use BVSTATE to manage its states.")) + .into_owned() + } else { + unavailable.to_string() + }; + let entry = tool_row(label, icon, message, &description); + if visibility { + container(entry).id("authoring-tool-visibility").into() + } else { + entry + } })) .spacing(2) .into() @@ -323,7 +345,12 @@ fn constraints() -> Element<'static, Message> { .into() } -pub fn view(selected_tab: Tab, width: f32, chrome: Chrome) -> Element<'static, Message> { +pub fn view( + selected_tab: Tab, + width: f32, + chrome: Chrome, + visibility_error: Option<&'static str>, +) -> Element<'static, Message> { let tabs = column(Tab::ALL.into_iter().map(|category| { let label = category.label(); let selected = category == selected_tab; @@ -372,7 +399,9 @@ pub fn view(selected_tab: Tab, width: f32, chrome: Chrome) -> Element<'static, M chrome, ), row![ - scrollable(catalog(selected_tab)).width(Fill).height(Fill), + scrollable(catalog(selected_tab, visibility_error)) + .width(Fill) + .height(Fill), tabs ] .spacing(4)