forked from 1b-agent/opencadstudio-upstream
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:
1 parent
f3973f08a7
commit
c48fac7ae9
9 files changed
+952
-7
No files matched your search
@@ -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::<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,
|
||||
""
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user