diff --git a/src/app/block_properties.rs b/src/app/block_properties.rs new file mode 100644 index 000000000..f5bfd9e14 --- /dev/null +++ b/src/app/block_properties.rs @@ -0,0 +1,249 @@ +use super::{Message, OpenCADStudio}; +use crate::scene::model::object::{PropSection, PropValue, Property}; +use codec::{EntityType, Handle}; +use iced::Task; + +impl OpenCADStudio { + fn block_property_target(&self, i: usize) -> Option<(String, Handle, Handle)> { + let tab = &self.tabs[i]; + if !tab.scene.selected_entities().is_empty() { + return None; + } + let session = tab.active_block_edit_session()?; + let record = tab.scene.document.block_records.get(&session.block_name)?; + (record.handle == session.br_handle).then(|| { + ( + record.name.clone(), + record.handle, + record.block_entity_handle, + ) + }) + } + + pub(super) fn open_block_property_hyperlink(&mut self, i: usize) -> bool { + let Some((name, definition, _)) = self.block_property_target(i) else { + return false; + }; + let (url, description) = + crate::scene::block_metadata::hyperlink(&self.tabs[i].scene.document, &name); + self.hyperlink_editor_block = Some((self.tabs[i].id, definition)); + self.hyperlink_editor_handles.clear(); + self.hyperlink_editor_url = url; + self.hyperlink_editor_description = description; + self.hyperlink_editor_mixed = false; + self.hyperlink_editor_dirty = false; + self.active_modal = Some(super::ModalKind::Hyperlink); + true + } + + pub(super) fn apply_block_property_hyperlink( + &mut self, + url: &str, + description: &str, + ) -> Task { + let i = self.active_tab; + let target = self.block_property_target(i).filter(|(_, definition, _)| { + self.hyperlink_editor_block == Some((self.tabs[i].id, *definition)) + }); + if let Some((name, definition, _)) = target { + let current = + crate::scene::block_metadata::hyperlink(&self.tabs[i].scene.document, &name); + let next = ( + url.trim().to_string(), + if url.trim().is_empty() { + String::new() + } else { + description.trim().to_string() + }, + ); + if current != next { + self.push_undo_snapshot(i, "Block hyperlink"); + crate::scene::block_metadata::set_hyperlink( + &mut self.tabs[i].scene.document, + definition, + &next.0, + &next.1, + ); + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); + self.finish_pending_history(i); + self.refresh_properties(); + } + } else { + self.command_line + .push_error(crate::t!("The block being edited has changed.").as_ref()); + } + self.close_active_modal(); + Task::none() + } + + pub(super) fn block_property_section(&self, i: usize) -> Option { + let (name, _, _) = self.block_property_target(i)?; + let doc = &self.tabs[i].scene.document; + let record = doc.block_records.get(&name)?; + let (annotative, orientation) = crate::scene::block_metadata::annotation_flags(doc, &name); + let yes_no = |value: bool| if value { "Yes" } else { "No" }.to_string(); + let choice = |label: &str, field, value: bool| Property { + label: crate::t!(label).into_owned(), + field, + value: PropValue::Choice { + selected: yes_no(value), + options: vec!["Yes".into(), "No".into()], + }, + }; + let mut orientation_row = choice( + "Match orientation to layout", + "bedit_match_orientation", + orientation, + ); + if !annotative { + orientation_row.value = PropValue::ReadOnlyWithTooltip { + value: crate::t!("No").into_owned(), + tooltip: crate::t!("Enable Annotative to match orientation to layout.") + .into_owned(), + }; + } + let (hyperlink, _) = crate::scene::block_metadata::hyperlink(doc, &name); + let units = super::properties::insunits_name(record.units).to_string(); + let mut unit_options: Vec<_> = (0..=24) + .map(|code| super::properties::insunits_name(code).to_string()) + .collect(); + if !unit_options.contains(&units) { + unit_options.push(units.clone()); + } + Some(PropSection { + title: crate::t!("Block").into_owned(), + props: vec![ + Property { + label: crate::t!("Block name").into_owned(), + field: "bedit_block_name", + value: PropValue::ReadOnly(name), + }, + choice("Annotative", "bedit_annotative", annotative), + orientation_row, + choice( + "Scale uniformly", + "bedit_scale_uniformly", + record.scale_uniformly, + ), + choice( + "Allow exploding", + "bedit_allow_exploding", + record.explodable, + ), + Property { + label: crate::t!("Units").into_owned(), + field: "bedit_units", + value: PropValue::Choice { + selected: units, + options: unit_options, + }, + }, + Property { + label: crate::t!("Description").into_owned(), + field: "bedit_description", + value: PropValue::PlainText(record.description.clone()), + }, + Property { + label: crate::t!("Hyperlink").into_owned(), + field: "bedit_hyperlink", + value: PropValue::Hyperlink(hyperlink), + }, + ], + }) + } + + pub(super) fn set_block_property(&mut self, field: &'static str, value: &str) -> Task { + let i = self.active_tab; + let Some((name, definition, marker)) = self.block_property_target(i) else { + self.command_line.push_error( + crate::t!("Block properties require an open Block Editor with no selection.") + .as_ref(), + ); + return Task::none(); + }; + let section = self.block_property_section(i).unwrap(); + let Some(row) = section.props.iter().find(|row| row.field == field) else { + return Task::none(); + }; + let current = match &row.value { + PropValue::Choice { selected, options } => { + if !options.iter().any(|option| option == value) { + self.command_line + .push_error(crate::t!("Value is not an available option.").as_ref()); + self.refresh_properties(); + return Task::none(); + } + selected.as_str() + } + PropValue::PlainText(text) | PropValue::Hyperlink(text) => text.as_str(), + _ => { + self.command_line + .push_error(crate::t!("Property cannot be edited.").as_ref()); + return Task::none(); + } + }; + if current == value { + return Task::none(); + } + self.push_undo_snapshot(i, "Block properties"); + let doc = &mut self.tabs[i].scene.document; + match field { + "bedit_annotative" | "bedit_match_orientation" => { + let (mut annotation, mut orientation) = + crate::scene::block_metadata::annotation_flags(doc, &name); + if field == "bedit_annotative" { + annotation = value == "Yes"; + } else { + orientation = value == "Yes"; + } + crate::scene::block_metadata::set_annotation_flags( + doc, + definition, + annotation, + orientation, + ); + } + "bedit_scale_uniformly" => { + doc.block_records.get_mut(&name).unwrap().scale_uniformly = value == "Yes" + } + "bedit_allow_exploding" => { + doc.block_records.get_mut(&name).unwrap().explodable = value == "Yes" + } + "bedit_units" => { + doc.block_records.get_mut(&name).unwrap().units = (0..=24) + .find(|code| super::properties::insunits_name(*code) == value) + .unwrap() + } + "bedit_description" => { + doc.block_records.get_mut(&name).unwrap().description = value.into(); + if let Some(EntityType::Block(block)) = doc.get_entity_mut(marker) { + block.description = value.into(); + } + } + "bedit_hyperlink" => { + let (_, description) = crate::scene::block_metadata::hyperlink(doc, &name); + crate::scene::block_metadata::set_hyperlink(doc, definition, value, &description); + } + _ => return Task::none(), + } + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); + self.finish_pending_history(i); + self.refresh_properties(); + self.refresh_block_palette(); + Task::none() + } + + pub(super) fn commit_block_property(&mut self, field: &'static str) -> Task { + let panel = &mut self.tabs[self.active_tab].properties; + panel.active_field = None; + let Some(value) = panel + .edit_buf + .remove(&crate::ui::properties::FieldKey::Geom(field)) + else { + return Task::none(); + }; + self.set_block_property(field, &value) + } +} diff --git a/src/app/block_properties_tests.rs b/src/app/block_properties_tests.rs new file mode 100644 index 000000000..31d9239c8 --- /dev/null +++ b/src/app/block_properties_tests.rs @@ -0,0 +1,517 @@ +use super::{Message, OpenCADStudio}; +use crate::scene::model::object::{PropValue, Property}; +use codec::entities::{Insert, Line}; +use codec::types::Vector3; +use codec::{CadDocument, EntityType}; +use std::io::Cursor; + +fn fixture() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.tabs[i].scene.define_block_raw( + "PROPERTIES", + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))], + ); + let reference = app.tabs[i] + .scene + .document + .add_entity(EntityType::Insert(Insert::new("PROPERTIES", Vector3::ZERO))) + .unwrap(); + let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", reference.value())); + app.refresh_properties(); + app +} + +fn row(app: &OpenCADStudio, field: &str) -> Property { + app.tabs[app.active_tab] + .properties + .sections + .iter() + .flat_map(|section| §ion.props) + .find(|property| property.field == field) + .unwrap() + .clone() +} + +fn edit(app: &mut OpenCADStudio, field: &str, value: &str) { + let property = row(app, field); + let _ = app + .set_property_value(property, &serde_json::json!(value)) + .unwrap(); + app.finish_pending_history(app.active_tab); +} + +#[test] +fn block_properties_extend_the_existing_panel_only_for_empty_editor_selection() { + let mut app = fixture(); + let i = app.active_tab; + let sections = &app.tabs[i].properties.sections; + assert_eq!(app.tabs[i].properties.title, "No selection"); + let block = sections + .iter() + .position(|section| section.title == "Block") + .unwrap(); + assert_eq!(sections[block - 1].title, "Misc"); + assert_eq!( + sections[block] + .props + .iter() + .map(|property| property.label.as_str()) + .collect::>(), + [ + "Block name", + "Annotative", + "Match orientation to layout", + "Scale uniformly", + "Allow exploding", + "Units", + "Description", + "Hyperlink" + ] + ); + assert_eq!( + row(&app, "bedit_block_name").value, + PropValue::ReadOnly("PROPERTIES".into()) + ); + let handle = app.tabs[i] + .scene + .document + .entities_in_block("PROPERTIES") + .find(|entity| matches!(entity, EntityType::Line(_))) + .unwrap() + .common() + .handle; + app.tabs[i].scene.selected.insert(handle); + app.refresh_properties(); + assert!( + !app.tabs[i].properties.sections.iter().any(|section| section + .props + .iter() + .any(|property| property.field.starts_with("bedit_"))) + ); + let before = app.tabs[i].scene.document.clone(); + let _ = app.update(Message::PropGeomChoiceChanged { + field: "bedit_allow_exploding", + value: "No".into(), + }); + assert_eq!(app.tabs[i].scene.document, before); + app.tabs[i].scene.deselect_all(); + let _ = app.dispatch_command("BEDIT_DISCARD"); + app.refresh_properties(); + assert!(!app.tabs[i] + .properties + .sections + .iter() + .any(|section| section.title == "Block")); +} + +#[test] +fn block_properties_edits_have_local_undo_redo_and_discard() { + let mut app = fixture(); + let i = app.active_tab; + let original = app.tabs[i].scene.document.clone(); + edit(&mut app, "bedit_units", "Meters"); + edit(&mut app, "bedit_scale_uniformly", "Yes"); + edit(&mut app, "bedit_allow_exploding", "No"); + edit(&mut app, "bedit_description", "Door = 2 + 3"); + edit(&mut app, "bedit_hyperlink", "https://example.com/door"); + let edited = app.tabs[i].scene.document.clone(); + let record = edited.block_records.get("PROPERTIES").unwrap(); + assert_eq!(record.units, 6); + assert!(record.scale_uniformly); + assert!(!record.explodable); + assert_eq!(record.description, "Door = 2 + 3"); + assert_eq!( + crate::scene::block_metadata::hyperlink(&edited, "PROPERTIES"), + ("https://example.com/door".into(), String::new()) + ); + assert!(app.block_edit_dirty(i)); + app.undo_steps(5); + assert_eq!( + app.tabs[i].scene.document.block_records, + original.block_records + ); + assert!(!app.block_edit_dirty(i)); + app.redo_steps(5); + assert_eq!(app.tabs[i].scene.document, edited); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert_eq!(app.tabs[i].scene.document, original); +} + +#[test] +fn block_properties_annotation_gates_orientation_and_preserves_unrelated_xdata() { + let mut app = fixture(); + let i = app.active_tab; + let definition = app.tabs[i] + .scene + .document + .block_records + .get("PROPERTIES") + .unwrap() + .handle; + let mut data = codec::xdata::ExtendedDataRecord::new("EXTRA_TAG"); + data.values = vec![codec::xdata::XDataValue::String("retained".into())]; + app.tabs[i] + .scene + .document + .set_object_xdata_record(definition, data.clone()); + assert!(matches!( + row(&app, "bedit_match_orientation").value, + PropValue::ReadOnlyWithTooltip { .. } + )); + let before = app.tabs[i].scene.document.clone(); + let _ = app.set_block_property("bedit_match_orientation", "Yes"); + assert_eq!(app.tabs[i].scene.document, before); + edit(&mut app, "bedit_annotative", "Yes"); + edit(&mut app, "bedit_match_orientation", "Yes"); + assert_eq!( + crate::scene::block_metadata::annotation_flags(&app.tabs[i].scene.document, "PROPERTIES"), + (true, true) + ); + assert_eq!( + app.tabs[i] + .scene + .document + .object_xdata(definition) + .unwrap() + .get_record("EXTRA_TAG"), + Some(&data) + ); + edit(&mut app, "bedit_annotative", "No"); + assert_eq!( + crate::scene::block_metadata::annotation_flags(&app.tabs[i].scene.document, "PROPERTIES"), + (false, false) + ); + app.undo_steps(1); + assert_eq!( + crate::scene::block_metadata::annotation_flags(&app.tabs[i].scene.document, "PROPERTIES"), + (true, true) + ); + assert!(matches!( + row(&app, "bedit_match_orientation").value, + PropValue::Choice { .. } + )); +} + +#[test] +fn block_properties_noop_invalid_and_readonly_edits_do_not_create_history() { + let mut app = fixture(); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + edit(&mut app, "bedit_units", "Unitless"); + let _ = app.set_block_property("bedit_units", "Bogus units"); + assert!(app + .set_property_value(row(&app, "bedit_block_name"), &serde_json::json!("RENAMED")) + .is_err()); + assert_eq!(app.tabs[i].scene.document, before); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + app.tabs[i] + .scene + .document + .block_records + .get_mut("PROPERTIES") + .unwrap() + .units = 99; + app.refresh_properties(); + edit(&mut app, "bedit_units", "Unknown"); + assert_eq!( + app.tabs[i] + .scene + .document + .block_records + .get("PROPERTIES") + .unwrap() + .units, + 99 + ); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); +} + +#[test] +fn block_properties_save_checkpoint_and_main_undo_restore_definition_metadata() { + let mut app = fixture(); + let i = app.active_tab; + let original = app.tabs[i].scene.document.clone(); + edit(&mut app, "bedit_units", "Millimeters"); + edit(&mut app, "bedit_annotative", "Yes"); + edit(&mut app, "bedit_match_orientation", "Yes"); + let _ = app.dispatch_command("BSAVE"); + assert!(!app.block_edit_dirty(i)); + let saved = app.tabs[i].scene.document.clone(); + edit(&mut app, "bedit_description", "discard this edit"); + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert_eq!( + app.tabs[i].scene.document.block_records, + saved.block_records + ); + assert_eq!( + crate::scene::block_metadata::annotation_flags(&app.tabs[i].scene.document, "PROPERTIES"), + (true, true) + ); + app.undo_steps(1); + assert_eq!( + app.tabs[i].scene.document.block_records, + original.block_records + ); + assert_eq!( + crate::scene::block_metadata::annotation_flags(&app.tabs[i].scene.document, "PROPERTIES"), + (false, false) + ); + app.redo_steps(1); + assert_eq!( + app.tabs[i].scene.document.block_records, + saved.block_records + ); +} + +#[test] +fn block_properties_hyperlink_editor_targets_definition_without_selecting_it() { + let mut app = fixture(); + let i = app.active_tab; + let definition = app.tabs[i] + .scene + .document + .block_records + .get("PROPERTIES") + .unwrap() + .handle; + let _ = app.update(Message::PropHyperlinkOpen); + assert!(app.hyperlink_editor_handles.is_empty()); + assert_eq!( + app.hyperlink_editor_block, + Some((app.tabs[i].id, definition)) + ); + let _ = app.update(Message::HyperlinkUrlChanged( + "https://example.com/link".into(), + )); + let _ = app.update(Message::HyperlinkDescriptionChanged( + "Definition link".into(), + )); + let _ = app.update(Message::HyperlinkApply); + app.finish_pending_history(i); + assert!(app.tabs[i].scene.selected_entities().is_empty()); + assert_eq!( + crate::scene::block_metadata::hyperlink(&app.tabs[i].scene.document, "PROPERTIES"), + ("https://example.com/link".into(), "Definition link".into()) + ); + assert!(app.hyperlink_editor_block.is_none()); + let _ = app.update(Message::PropHyperlinkOpen); + let _ = app.update(Message::HyperlinkRemove); + app.finish_pending_history(i); + assert_eq!( + crate::scene::block_metadata::hyperlink(&app.tabs[i].scene.document, "PROPERTIES"), + (String::new(), String::new()) + ); + app.undo_steps(1); + assert!( + matches!(row(&app, "bedit_hyperlink").value, PropValue::Hyperlink(ref value) if value == "https://example.com/link") + ); +} + +fn check_round_trip(doc: &CadDocument) { + let record = doc.block_records.get("PROPERTIES").unwrap(); + assert_eq!(record.units, 4); + assert!(record.scale_uniformly); + assert!(!record.explodable); + assert_eq!(record.description, "stored description"); + assert_eq!( + crate::scene::block_metadata::annotation_flags(doc, "PROPERTIES"), + (true, true) + ); + assert_eq!( + crate::scene::block_metadata::hyperlink(doc, "PROPERTIES"), + ("https://example.com/stored".into(), String::new()) + ); +} + +#[test] +fn block_properties_metadata_survives_dwg_and_dxf_save_and_reopen() { + let mut app = fixture(); + for (field, value) in [ + ("bedit_units", "Millimeters"), + ("bedit_scale_uniformly", "Yes"), + ("bedit_allow_exploding", "No"), + ("bedit_description", "stored description"), + ("bedit_hyperlink", "https://example.com/stored"), + ("bedit_annotative", "Yes"), + ("bedit_match_orientation", "Yes"), + ] { + edit(&mut app, field, value); + } + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BCLOSE"); + let doc = &app.tabs[app.active_tab].scene.document; + let bytes = codec::DwgWriter::write_to_vec(doc).unwrap(); + let read = codec::DwgReader::from_stream(Cursor::new(bytes)) + .read() + .unwrap(); + check_round_trip(&read); + let mut changed = read.clone(); + let definition = changed.block_records.get("PROPERTIES").unwrap().handle; + crate::scene::block_metadata::set_annotation_flags(&mut changed, definition, false, false); + crate::scene::block_metadata::set_hyperlink( + &mut changed, + definition, + "https://example.com/replaced", + "Replaced", + ); + let reopened = codec::DwgReader::from_stream(Cursor::new( + codec::DwgWriter::write_to_vec(&changed).unwrap(), + )) + .read() + .unwrap(); + assert_eq!( + crate::scene::block_metadata::annotation_flags(&reopened, "PROPERTIES"), + (false, false) + ); + assert_eq!( + crate::scene::block_metadata::hyperlink(&reopened, "PROPERTIES"), + ("https://example.com/replaced".into(), "Replaced".into()) + ); + let bytes = codec::DxfWriter::new(doc).write_to_vec().unwrap(); + let read = codec::DxfReader::from_reader(Cursor::new(bytes)) + .unwrap() + .read() + .unwrap(); + check_round_trip(&read); +} + +#[test] +fn block_properties_creation_options_persist_orientation_on_the_definition() { + let mut scene = crate::scene::Scene::new(); + let line = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_X, + ))); + let mut options = crate::scene::CreateBlockOptions { + name: "CREATED".into(), + handles: vec![line], + base_point: glam::DVec3::ZERO, + world_to_block: codec::types::Transform::identity(), + block_to_world: codec::types::Transform::identity(), + mode: crate::ui::window::block_definition::BlockObjectMode::Retain, + annotative: true, + match_orientation: true, + scale_uniformly: true, + allow_exploding: true, + unit: 0, + description: String::new(), + hyperlink_url: "https://example.com/created".into(), + hyperlink_desc: "Created definition".into(), + redefine: false, + }; + scene.create_block_with_options(options.clone()).unwrap(); + assert_eq!( + crate::scene::block_metadata::annotation_flags(&scene.document, "CREATED"), + (true, true) + ); + let bytes = codec::DwgWriter::write_to_vec(&scene.document).unwrap(); + let read = codec::DwgReader::from_stream(Cursor::new(bytes)) + .read() + .unwrap(); + assert_eq!( + crate::scene::block_metadata::annotation_flags(&read, "CREATED"), + (true, true) + ); + assert_eq!( + crate::scene::block_metadata::hyperlink(&read, "CREATED"), + ( + "https://example.com/created".into(), + "Created definition".into() + ) + ); + options.redefine = true; + options.annotative = false; + options.hyperlink_url.clear(); + options.hyperlink_desc.clear(); + scene.create_block_with_options(options).unwrap(); + assert_eq!( + crate::scene::block_metadata::annotation_flags(&scene.document, "CREATED"), + (false, false) + ); + assert_eq!( + crate::scene::block_metadata::hyperlink(&scene.document, "CREATED"), + (String::new(), String::new()) + ); +} + +#[test] +fn block_properties_hyperlink_cancel_and_stale_context_do_not_modify_a_definition() { + let mut app = fixture(); + let i = app.active_tab; + let before = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + let _ = app.update(Message::PropHyperlinkOpen); + let _ = app.update(Message::HyperlinkUrlChanged( + "https://example.com/cancelled".into(), + )); + let _ = app.update(Message::HyperlinkCancel); + assert_eq!(app.tabs[i].scene.document, before); + assert!(app.hyperlink_editor_block.is_none()); + let _ = app.update(Message::PropHyperlinkOpen); + let _ = app.update(Message::HyperlinkUrlChanged( + "https://example.com/stale".into(), + )); + app.hyperlink_editor_block = Some((app.tabs[i].id + 1, codec::Handle::new(1))); + let _ = app.update(Message::HyperlinkApply); + assert_eq!(app.tabs[i].scene.document, before); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + assert!(app.hyperlink_editor_block.is_none()); +} + +#[test] +fn block_properties_staged_values_do_not_follow_a_different_definition() { + use crate::ui::properties::FieldKey; + let mut app = fixture(); + let i = app.active_tab; + let key = FieldKey::Geom("bedit_description"); + app.tabs[i] + .properties + .edit_buf + .insert(key.clone(), "uncommitted".into()); + app.tabs[i].properties.active_field = Some(key.clone()); + app.refresh_properties(); + assert_eq!( + app.tabs[i] + .properties + .edit_buf + .get(&key) + .map(String::as_str), + Some("uncommitted") + ); + let _ = app.dispatch_command("BEDIT_DISCARD"); + app.tabs[i].scene.define_block_raw( + "SECOND", + Vector3::ZERO, + vec![EntityType::Line(Line::from_points( + Vector3::ZERO, + Vector3::UNIT_Y, + ))], + ); + let reference = app.tabs[i] + .scene + .document + .add_entity(EntityType::Insert(Insert::new("SECOND", Vector3::ZERO))) + .unwrap(); + let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", reference.value())); + app.refresh_properties(); + assert!(!app.tabs[i].properties.edit_buf.contains_key(&key)); + assert_eq!(app.tabs[i].properties.active_field, None); + let _ = app.commit_block_property("bedit_description"); + assert_eq!( + app.tabs[i] + .scene + .document + .block_records + .get("SECOND") + .unwrap() + .description, + "" + ); +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 4558ca2f6..dcc194222 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -12,6 +12,9 @@ mod block_editor; #[cfg(test)] mod block_editor_tests; mod block_save_as; +mod block_properties; +#[cfg(test)] +mod block_properties_tests; mod block_test; #[cfg(test)] mod block_test_tests; @@ -1099,6 +1102,7 @@ pub(super) struct OpenCADStudio { active_modal: Option, /// Selection and staged values owned by the Properties hyperlink dialog. hyperlink_editor_handles: Vec, + hyperlink_editor_block: Option<(u64, codec::Handle)>, hyperlink_editor_url: String, hyperlink_editor_description: String, hyperlink_editor_mixed: bool, @@ -4839,6 +4843,7 @@ impl OpenCADStudio { color_books_read: false, active_modal: None, hyperlink_editor_handles: Vec::new(), + hyperlink_editor_block: None, hyperlink_editor_url: String::new(), hyperlink_editor_description: String::new(), hyperlink_editor_mixed: false, diff --git a/src/app/properties.rs b/src/app/properties.rs index 8f15a3c9a..c1ad8d851 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1780,6 +1780,7 @@ impl OpenCADStudio { // Which entities the previous panel was built for — an uncommitted // edit buffer only survives a rebuild for the *same* selection. let prev_handles = std::mem::take(&mut self.tabs[i].properties.source_handles); + let previous_definition = self.tabs[i].properties.block_definition; let selected_group = self.tabs[i].properties.selected_group.clone(); // Seed the per-thread unit context from the document header so the @@ -2026,7 +2027,7 @@ impl OpenCADStudio { value => value.to_string(), }) .collect(); - let sections = vec![ + let mut sections = vec![ PropSection { title: t!("General").into_owned(), props: vec![ @@ -2261,8 +2262,14 @@ impl OpenCADStudio { }, }, ]; + if let Some(section) = self.block_property_section(i) { + sections.insert(5, section); + } ui::PropertiesPanel { title: t!("No selection").into_owned(), + block_definition: tab + .active_block_edit_session() + .map(|session| session.br_handle), choice_combos: sections .iter() .flat_map(|section| section.props.iter()) @@ -2469,7 +2476,9 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", // Carry the in-progress edits only when the selection is unchanged // (a commit-triggered rebuild); a genuine selection change starts // with a clean buffer so no stale value leaks onto the new entity. - panel.edit_buf = if prev_handles == new_handles { + let same_target = + prev_handles == new_handles && previous_definition == panel.block_definition; + panel.edit_buf = if same_target { edit_buf } else { Default::default() @@ -2477,11 +2486,7 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", // The active-row highlight only survives a rebuild for the same // selection (like the edit buffer); a selection change clears it so // an old row isn't marked active against new content. - panel.active_field = if prev_handles == new_handles { - active_field - } else { - None - }; + panel.active_field = if same_target { active_field } else { None }; panel.expanded_groups = expanded_groups; panel.collapsed_sections = collapsed_sections; panel.source_handles = new_handles; diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 29fb16bb7..5acf99cea 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2655,6 +2655,9 @@ impl OpenCADStudio { field: &'static str, value: String, ) -> Task { + if field.starts_with("bedit_") { + return self.set_block_property(field, &value); + } if matches!(field, "dyn_constraint_form" | "dyn_constraint_reference") { return self.on_dynamic_dimension_choice(field, &value); } @@ -3886,6 +3889,9 @@ impl OpenCADStudio { } pub(super) fn on_prop_geom_commit(&mut self, field: &'static str) -> Task { + if field.starts_with("bedit_") { + return self.commit_block_property(field); + } use crate::ui::window::named_parameters::ParamField; // A dynamic dimension's Name/Expression rows edit its parameter. let dynamic_field = match field { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 263bba5e6..75f0de896 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -243,6 +243,7 @@ impl OpenCADStudio { Some(WriteBlock) => self.wblock = None, Some(Hyperlink) => { self.hyperlink_editor_handles.clear(); + self.hyperlink_editor_block = None; self.hyperlink_editor_url.clear(); self.hyperlink_editor_description.clear(); self.hyperlink_editor_mixed = false; @@ -5251,7 +5252,11 @@ impl OpenCADStudio { } Message::PropHyperlinkOpen => { let i = self.active_tab; + self.hyperlink_editor_block = None; let handles = self.property_target_handles(i); + if handles.is_empty() && self.open_block_property_hyperlink(i) { + return Task::none(); + } if handles.is_empty() { return Task::none(); } @@ -5311,6 +5316,9 @@ impl OpenCADStudio { return Task::none(); } let description = self.hyperlink_editor_description.trim().to_owned(); + if self.hyperlink_editor_block.is_some() { + return self.apply_block_property_hyperlink(&url, &description); + } if let Some(block_def) = self.block_definition.as_mut() { block_def.hyperlink_url = url; block_def.hyperlink_desc = description; @@ -5340,6 +5348,9 @@ impl OpenCADStudio { Task::none() } Message::HyperlinkRemove => { + if self.hyperlink_editor_block.is_some() { + return self.apply_block_property_hyperlink("", ""); + } if let Some(block_def) = self.block_definition.as_mut() { block_def.hyperlink_url.clear(); block_def.hyperlink_desc.clear(); @@ -5858,6 +5869,7 @@ impl OpenCADStudio { Task::none() } Message::BlockDefHyperlink => { + self.hyperlink_editor_block = None; if let Some(state) = self.block_definition.as_ref() { self.hyperlink_editor_url = state.hyperlink_url.clone(); self.hyperlink_editor_description = state.hyperlink_desc.clone(); diff --git a/src/scene/block_metadata.rs b/src/scene/block_metadata.rs new file mode 100644 index 000000000..182824ee5 --- /dev/null +++ b/src/scene/block_metadata.rs @@ -0,0 +1,147 @@ +//! Definition-level annotation metadata shared by block creation and editing. + +use codec::xdata::{ExtendedData, ExtendedDataRecord, XDataValue}; +use codec::{CadDocument, EntityType, Handle}; + +fn flag(data: Option<&ExtendedData>, app: &str) -> Option { + data? + .get_record(app)? + .values + .iter() + .filter_map(|value| match value { + XDataValue::Integer16(value) => Some(*value != 0), + _ => None, + }) + .last() +} + +fn link(data: Option<&ExtendedData>) -> Option<(String, String)> { + let mut strings = data? + .get_record("PE_URL")? + .values + .iter() + .filter_map(|value| match value { + XDataValue::String(text) => Some(text.trim().to_string()), + _ => None, + }); + Some(( + strings.next().unwrap_or_default(), + strings.next().unwrap_or_default(), + )) +} + +pub(crate) fn hyperlink(document: &CadDocument, name: &str) -> (String, String) { + let Some(record) = document.block_records.get(name) else { + return Default::default(); + }; + link(document.object_xdata(record.handle)) + .or_else(|| { + link( + document + .get_entity(record.block_entity_handle) + .map(|entity| &entity.common().extended_data), + ) + }) + .unwrap_or_default() +} + +fn ensure_application(document: &mut CadDocument, app: &str) { + if !document.app_ids.contains(app) { + let mut entry = codec::tables::AppId::new(app); + entry.handle = document.allocate_handle(); + document + .app_ids + .add(entry) + .expect("new metadata application"); + } +} + +pub(crate) fn set_hyperlink( + document: &mut CadDocument, + definition: Handle, + url: &str, + description: &str, +) { + if url.trim().is_empty() { + document.remove_object_xdata_record(definition, "PE_URL"); + } else { + ensure_application(document, "PE_URL"); + let mut record = ExtendedDataRecord::new("PE_URL"); + record.values = vec![ + XDataValue::String(url.trim().into()), + XDataValue::ControlString("{".into()), + ]; + if !description.trim().is_empty() { + record + .values + .push(XDataValue::String(description.trim().into())); + } + record.values.extend([ + XDataValue::ControlString("{".into()), + XDataValue::Integer32(0), + XDataValue::ControlString("}".into()), + XDataValue::ControlString("}".into()), + ]); + document.set_object_xdata_record(definition, record); + } + let marker = document + .block_records + .iter() + .find(|record| record.handle == definition) + .map(|record| record.block_entity_handle); + if let Some(marker) = marker { + super::view::dispatch::set_entity_xdata(document, marker, "PE_URL", None); + } +} + +pub(crate) fn annotation_flags(document: &CadDocument, name: &str) -> (bool, bool) { + let Some(record) = document.block_records.get(name) else { + return (false, false); + }; + let data = document.object_xdata(record.handle); + let marker = document.get_entity(record.block_entity_handle); + let legacy = marker.map(|entity| &entity.common().extended_data); + let annotative = flag(data, "AcadAnnotative") + .or_else(|| flag(legacy, "AcadAnnotative")) + .unwrap_or(false); + let orientation = flag(data, "AcadAnnoPO") + .or_else(|| flag(legacy, "AcadAnnoPO")) + .unwrap_or(false); + (annotative, annotative && orientation) +} + +pub(crate) fn set_annotation_flags( + document: &mut CadDocument, + definition: Handle, + annotative: bool, + orientation: bool, +) { + for app in ["AcadAnnotative", "AcadAnnoPO"] { + ensure_application(document, app); + } + let mut annotation = ExtendedDataRecord::new("AcadAnnotative"); + annotation.values = vec![ + XDataValue::String("AnnotativeData".into()), + XDataValue::ControlString("{".into()), + XDataValue::Integer16(1), + XDataValue::Integer16(i16::from(annotative)), + XDataValue::ControlString("}".into()), + ]; + document.set_object_xdata_record(definition, annotation); + let mut paper = ExtendedDataRecord::new("AcadAnnoPO"); + paper.values = vec![XDataValue::Integer16(i16::from(annotative && orientation))]; + document.set_object_xdata_record(definition, paper); + // Older definitions stored the flag on the BLOCK marker. Keep one owner. + let marker = document + .block_records + .iter() + .find(|record| record.handle == definition) + .map(|record| record.block_entity_handle); + if let Some(marker) = marker { + if matches!(document.get_entity(marker), Some(EntityType::Block(_))) { + for app in ["AcadAnnotative", "AcadAnnoPO"] { + super::view::dispatch::set_entity_xdata(document, marker, app, None); + } + } + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 380663425..9c425c08c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -7,6 +7,7 @@ // cache — block-definition and property caches pub mod annotative; pub mod assoc_array; +pub(crate) mod block_metadata; pub mod block_service; pub mod block_visibility; pub mod cache; diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 5f59f7209..29c67ba66 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -595,6 +595,8 @@ pub struct PropertiesPanel { /// commit onto a different entity (e.g. two title blocks sharing a `REV1` /// tag). pub source_handles: Vec, + /// Definition shown by the no-selection Block section, separate from entity targets. + pub block_definition: Option, /// Whether the quick color picker dropdown is open. pub color_picker_open: bool, /// Whether the MTEXT background-colour picker dropdown is open. Separate @@ -657,6 +659,7 @@ impl Default for PropertiesPanel { hatch_pattern_focus: 0, edit_buf: HashMap::default(), source_handles: vec![], + block_definition: None, color_picker_open: false, bg_color_picker_open: false, open_color_field: None,