From c48fac7ae9a733be6e39d0b7a03a63eed2a7dc57 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Thu, 15 Oct 2026 09:13:38 +0100 Subject: [PATCH] feat(block-editor): edit definition properties in the Properties panel Add the no-selection Block section with annotation, orientation, scaling, exploding, units, description and hyperlink controls. Integrate definition edits with editor history, save checkpoints and discard, and keep staged text tied to its definition. Persist annotation and hyperlink metadata on the block record, migrate legacy marker values on edit, and honor orientation and hyperlink descriptions during block creation. Cover native DWG/DXF roundtrips and replacement after reopening. --- src/app/block_properties.rs | 249 ++++++++++++++ src/app/block_properties_tests.rs | 517 ++++++++++++++++++++++++++++++ src/app/mod.rs | 5 + src/app/properties.rs | 19 +- src/app/update/command.rs | 6 + src/app/update/mod.rs | 12 + src/scene/block_metadata.rs | 147 +++++++++ src/scene/mod.rs | 1 + src/ui/properties.rs | 3 + 9 files changed, 952 insertions(+), 7 deletions(-) create mode 100644 src/app/block_properties.rs create mode 100644 src/app/block_properties_tests.rs create mode 100644 src/scene/block_metadata.rs 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,