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.
This commit is contained in:
Karim Jerbi committed 2026-10-15 09:45:58 +01:00
1 parent f3973f08a7
commit c48fac7ae9
9 files changed
+952 -7

No files matched your search

+249
View File
@@ -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<Message> {
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<PropSection> {
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<Message> {
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<Message> {
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)
}
}
+517
View File
@@ -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| &section.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::<Vec<_>>(),
[
"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,
""
);
}
+5
View File
@@ -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<ModalKind>,
/// Selection and staged values owned by the Properties hyperlink dialog.
hyperlink_editor_handles: Vec<codec::Handle>,
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,
+12 -7
View File
@@ -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;
+6
View File
@@ -2655,6 +2655,9 @@ impl OpenCADStudio {
field: &'static str,
value: String,
) -> Task<Message> {
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<Message> {
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 {
+12
View File
@@ -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();
+147
View File
@@ -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<bool> {
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);
}
}
}
}
+1
View File
@@ -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;
+3
View File
@@ -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<Handle>,
/// Definition shown by the no-selection Block section, separate from entity targets.
pub block_definition: Option<Handle>,
/// 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,