diff --git a/src/app/block_actions.rs b/src/app/block_actions.rs index b838796a4..10610cb9e 100644 --- a/src/app/block_actions.rs +++ b/src/app/block_actions.rs @@ -102,6 +102,7 @@ impl OpenCADStudio { } } crate::command::CmdResult::EvaluateBlockParameter { + insert, document, definition, parameter, @@ -110,6 +111,10 @@ impl OpenCADStudio { if self.tabs[i].id != document { return Task::none(); } + if let Some(insert) = insert { + return self + .evaluate_reference_parameter(insert, definition, parameter, position); + } let Some(test) = self.tabs[i].block_test.as_ref() else { return Task::none(); }; @@ -193,6 +198,7 @@ impl OpenCADStudio { let i = self.active_tab; let tab = &self.tabs[i]; let Some(test) = tab.block_test.as_ref() else { + self.refresh_reference_action_grips(wo); return; }; if tab.selected_handle != Some(test.insert) { @@ -237,7 +243,7 @@ impl OpenCADStudio { let i = self.active_tab; let tab = &self.tabs[i]; let Some(test) = tab.block_test.as_ref() else { - return false; + return self.start_reference_action_grip(handle, id); }; if handle != test.insert { return false; @@ -252,6 +258,7 @@ impl OpenCADStudio { return false; }; let command = crate::modules::block_editor::action::TestParameterCommand { + insert: None, document: tab.id, definition: test.definition, parameter: p.handle, @@ -263,3 +270,140 @@ impl OpenCADStudio { true } } + +impl OpenCADStudio { + fn refresh_reference_action_grips(&mut self, wo: [f64; 3]) { + let i = self.active_tab; + let tab = &self.tabs[i]; + if tab.active_block_edit_session().is_some() || tab.scene.selected.len() != 1 { + return; + } + let Some(handle) = tab.selected_handle else { + return; + }; + let Some(def) = actions::instance::definition(&tab.scene.document, handle) else { + return; + }; + let Some(EntityType::Insert(reference)) = tab.scene.document.get_entity(handle) else { + return; + }; + let transform = + crate::scene::render_graph::insert_transform(&tab.scene.document, reference); + let all = actions::actions(&tab.scene.document, def); + let mut grips = vec![]; + for (index, p) in actions::parameters(&tab.scene.document, def, Kind::Move) + .into_iter() + .enumerate() + { + if !all.iter().any(|(_, _, _, id)| *id == p.id) { + continue; + } + let Ok(local) = actions::instance::current(&tab.scene.document, handle, &p) else { + continue; + }; + let world = + actions::v(transform.apply(actions::native(local))) - glam::DVec3::from_array(wo); + grips.push(crate::scene::model::object::GripDef { + id: TEST_GRIP_START + index, + world, + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Triangle, + dir: None, + axis: None, + }); + } + for grip in grips { + self.tabs[i].selected_grips.push(grip); + self.tabs[i].selected_grip_handles.push(handle); + } + } + fn start_reference_action_grip(&mut self, handle: Handle, id: usize) -> bool { + let i = self.active_tab; + let tab = &self.tabs[i]; + if tab.active_block_edit_session().is_some() || tab.selected_handle != Some(handle) { + return false; + } + let Some(def) = actions::instance::definition(&tab.scene.document, handle) else { + return false; + }; + if tab.scene.is_layer_locked(handle) { + return true; + } + let Some(p) = actions::parameters(&tab.scene.document, def, Kind::Move) + .get(id - TEST_GRIP_START) + .cloned() + else { + return false; + }; + let command = crate::modules::block_editor::action::TestParameterCommand { + insert: Some(handle), + document: tab.id, + definition: def, + parameter: p.handle, + }; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.grip_hover = None; + self.grip_popup = None; + true + } + fn evaluate_reference_parameter( + &mut self, + handle: Handle, + definition: Handle, + parameter: Handle, + position: glam::DVec3, + ) -> Task { + let i = self.active_tab; + let tab = &self.tabs[i]; + if tab.active_block_edit_session().is_some() + || tab.block_test.is_some() + || actions::instance::definition(&tab.scene.document, handle) != Some(definition) + { + return Task::none(); + } + if tab.scene.is_layer_locked(handle) { + return Task::none(); + } + let Some(EntityType::Insert(reference)) = tab.scene.document.get_entity(handle) else { + return Task::none(); + }; + let Some(inverse) = crate::scene::inverse_affine( + &crate::scene::render_graph::insert_transform(&tab.scene.document, reference), + ) else { + return Task::none(); + }; + let local = actions::v(inverse.apply(actions::native(position))); + match actions::instance::evaluate(&tab.scene.document, handle, parameter, local) { + Ok(staged) => { + if let Some(mut staged) = staged { + self.push_undo_snapshot(i, "Block reference parameter"); + let doc = &mut self.tabs[i].scene.document; + let changes = staged + .entities() + .filter(|e| doc.get_entity(e.common().handle) != Some(*e)) + .cloned() + .collect::>(); + // Structure swapping retains the live store and its transaction recorder. + doc.swap_structure(staged.snapshot_structure()); + for entity in changes { + let h = entity.common().handle; + if let Some(current) = doc.get_entity_mut(h) { + *current = entity + } else { + doc.restore_entity_arc(std::sync::Arc::new(entity)); + } + } + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.finish_pending_history(i); + } + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + self.refresh_properties(); + } + Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), + } + Task::none() + } +} diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 6bc41e9f9..1e9dfe147 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -59,6 +59,125 @@ fn feed(app: &mut OpenCADStudio, text: &str) { let _ = app.update(Message::CommandSubmit); } +#[test] +fn block_actions_drawing_grips_history_cancel_transform_and_reopen() { + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2"); + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].clone(); + let targets = members(&app); + actions::create( + &mut app.tabs[i].scene.document, + def, + Kind::Move, + p.handle, + &targets, + glam::DVec3::ZERO, + ) + .unwrap(); + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BEDIT_DISCARD"); + let sibling = app.tabs[i] + .scene + .document + .add_entity(EntityType::Insert(Insert::new( + "EDIT_ME", + Vector3::new(40., 0., 0.), + ))) + .unwrap(); + let EntityType::Insert(ins) = app.tabs[i] + .scene + .document + .get_entity_mut(reference) + .unwrap() + else { + panic!() + }; + ins.insert_point = Vector3::new(20., 30., 0.); + ins.rotation = std::f64::consts::FRAC_PI_2; + ins.set_x_scale(2.); + ins.set_y_scale(2.); + ins.set_z_scale(2.); + let original = app.tabs[i].scene.document.clone(); + app.tabs[i].scene.selected.insert(reference); + app.tabs[i].selected_handle = Some(reference); + app.refresh_properties(); + let grip = app.tabs[i] + .selected_grips + .iter() + .find(|g| super::block_actions::is_test_grip(g.id)) + .unwrap() + .clone(); + assert!(grip.world.distance(glam::DVec3::new(20., 50., 0.)) < 1e-8); + let depth = app.tabs[i].history.undo_stack.len(); + assert!(app.start_test_action_grip(reference, grip.id)); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[i].scene.document, original); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + // Escape also clears the ordinary drawing selection. + app.tabs[i].scene.selected.insert(reference); + app.refresh_properties(); + assert!(app.start_test_action_grip(reference, grip.id)); + feed(&mut app, "20,70"); + assert!(app.tabs[i].active_cmd.is_none()); + let changed = app.tabs[i].scene.document.clone(); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1); + let EntityType::Insert(ins) = changed.get_entity(reference).unwrap() else { + panic!() + }; + assert_ne!(ins.block_name, "EDIT_ME"); + let member = changed + .block_records + .get(&ins.block_name) + .unwrap() + .entity_handles[0]; + let EntityType::Line(line) = changed.get_entity(member).unwrap() else { + panic!() + }; + assert!((line.start.x - 10.).abs() < 1e-8); + assert_eq!( + changed.get_entity(targets[0]), + original.get_entity(targets[0]) + ); + assert_eq!(changed.get_entity(sibling), original.get_entity(sibling)); + app.undo_steps(1); + assert_eq!( + app.tabs[i].scene.document.get_entity(reference), + original.get_entity(reference) + ); + assert!(app.tabs[i].scene.document.get_entity(member).is_none()); + app.redo_steps(1); + assert_eq!( + app.tabs[i].scene.document.get_entity(member), + changed.get_entity(member) + ); + assert_eq!( + app.tabs[i].scene.document.get_entity(reference), + changed.get_entity(reference) + ); + let bytes = codec::DwgWriter::write_to_vec(&app.tabs[i].scene.document).unwrap(); + app.tabs[i].scene.document = codec::DwgReader::from_stream(std::io::Cursor::new(bytes)) + .read() + .unwrap(); + app.refresh_properties(); + let grip = app.tabs[i] + .selected_grips + .iter() + .find(|g| super::block_actions::is_test_grip(g.id)) + .unwrap(); + assert!(grip.world.distance(glam::DVec3::new(20., 70., 0.)) < 1e-8); + let id = grip.id; + assert!(app.start_test_action_grip(reference, id)); + feed(&mut app, "20,50"); + let EntityType::Line(line) = app.tabs[i].scene.document.get_entity(member).unwrap() else { + panic!() + }; + assert!(line.start.x.abs() < 1e-8); +} + #[test] fn authoring_edit_basepoint_attribute_relocation_undo_and_cancel() { let (mut app, reference) = fixture(); @@ -478,6 +597,7 @@ fn block_actions_command_selection_cancel_undo_and_test_block_isolation() { let shared_before = app.tabs[test].scene.document.clone(); let id = app.tabs[test].id; let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + insert: None, document: id, definition, parameter: p.handle, @@ -558,6 +678,7 @@ fn block_actions_test_reference_transform_visibility_and_noop_history() { .apply(actions::native(p.end)), ); let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + insert: None, document: id, definition, parameter: p.handle, @@ -596,6 +717,7 @@ fn block_actions_test_reference_transform_visibility_and_noop_history() { ); let before = app.tabs[i].scene.document.clone(); let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + insert: None, document: id, definition, parameter: p.handle, diff --git a/src/command.rs b/src/command.rs index a1aa22bf0..706852f09 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1530,6 +1530,7 @@ pub enum CmdResult { location: DVec3, }, EvaluateBlockParameter { + insert: Option, document: u64, definition: Handle, parameter: Handle, diff --git a/src/modules/block_editor/action.rs b/src/modules/block_editor/action.rs index 03aced724..805709bac 100644 --- a/src/modules/block_editor/action.rs +++ b/src/modules/block_editor/action.rs @@ -178,19 +178,25 @@ impl CadCommand for ActionCommand { } pub(crate) struct TestParameterCommand { + pub insert: Option, pub document: u64, pub definition: Handle, pub parameter: Handle, } impl CadCommand for TestParameterCommand { fn name(&self) -> &'static str { - "BTESTBLOCK" + if self.insert.is_some() { + "BLOCKPARAMETERGRIP" + } else { + "BTESTBLOCK" + } } fn prompt(&self) -> String { - crate::t!("Test Block Specify the new parameter endpoint (Escape cancels):").into_owned() + crate::t!("Specify the new block parameter endpoint (Escape cancels):").into_owned() } fn on_point(&mut self, position: DVec3) -> CmdResult { CmdResult::EvaluateBlockParameter { + insert: self.insert, document: self.document, definition: self.definition, parameter: self.parameter, diff --git a/src/scene/block_actions.rs b/src/scene/block_actions.rs index 17de6ee9f..156f6b9e7 100644 --- a/src/scene/block_actions.rs +++ b/src/scene/block_actions.rs @@ -1,4 +1,4 @@ -//! Native Move/Scale authoring and deterministic evaluation in Test Block. +//! Native Move/Scale authoring and deterministic dynamic-block evaluation. use super::block_authoring::{self as authoring, *}; use codec::objects::{ BlockAction, BlockActionOffsets, BlockActionWithBasePoint, BlockBasePointAction, @@ -7,6 +7,7 @@ use codec::objects::{ use codec::types::{Transform, Vector3}; use codec::{CadDocument, EntityType, Handle}; use glam::DVec3; +pub(crate) mod instance; #[cfg(test)] mod tests; @@ -379,15 +380,15 @@ pub(crate) fn evaluate( position: DVec3, ) -> Result { let graph = authoring::graph(baseline, definition)? - .ok_or("The test authoring graph is unavailable.")?; + .ok_or("The block authoring graph is unavailable.")?; if baseline.objects.values().any(|o|matches!(o,ObjectType::DynamicBlock(o) if o.owner==graph && matches!(&o.data, DynamicBlockData::StretchAction(_)|DynamicBlockData::PolarStretchAction(_)|DynamicBlockData::RotateAction(_)|DynamicBlockData::FlipAction(_)|DynamicBlockData::ArrayAction(_)))) { - return Err("This block contains action types not supported in Test Block yet."); + return Err("This block contains action types not supported yet."); } let p = parameters(baseline, definition, Kind::Move) .into_iter() .find(|p| p.handle == parameter) - .ok_or("The test parameter is unavailable.")?; + .ok_or("The block parameter is unavailable.")?; if !position.is_finite() { return Err("Specify a finite parameter position."); } @@ -408,16 +409,16 @@ pub(crate) fn evaluate( let ObjectType::DynamicBlock(o) = staged .objects .get_mut(&p.grip) - .ok_or("The test grip is unavailable.")? + .ok_or("The parameter grip is unavailable.")? else { - return Err("The test grip is unavailable."); + return Err("The parameter grip is unavailable."); }; match &mut o.data { DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => { g.location = native(position) } DynamicBlockData::LinearGrip(g) => g.grip.location = native(position), - _ => return Err("The test grip is unavailable."), + _ => return Err("The parameter grip is unavailable."), } let all = actions(baseline, definition); let mut affected = std::collections::BTreeSet::new(); @@ -445,7 +446,7 @@ pub(crate) fn evaluate( .find(|p| p.id == *id) .ok_or("An action has an unsupported parameter binding.")?; if !test_parameter_supported(baseline, q.handle) { - return Err("This parameter uses value constraints or chaining not supported in Test Block yet."); + return Err("This parameter uses value constraints or chaining not supported yet."); } let end = current(&staged, &q); let transform = match kind { @@ -464,7 +465,7 @@ pub(crate) fn evaluate( // Imported actions with custom multipliers/offsets/chaining must // not silently behave like the simple actions authored here. if !a.action_ids.is_empty() { - return Err("Chained actions are not supported in Test Block yet."); + return Err("Chained actions are not supported yet."); } let ObjectType::DynamicBlock(o) = &baseline.objects[action_handle] else { unreachable!() @@ -499,7 +500,7 @@ pub(crate) fn evaluate( ] } => { - return Err("This Move action uses options not supported in Test Block yet.") + return Err("This Move action uses options not supported yet.") } DynamicBlockData::ScaleAction(s) if !s.action.dependent @@ -515,7 +516,7 @@ pub(crate) fn evaluate( name: name.into(), }) => { - return Err("This Scale action uses options not supported in Test Block yet.") + return Err("This Scale action uses options not supported yet.") } _ => {} } diff --git a/src/scene/block_actions/instance.rs b/src/scene/block_actions/instance.rs new file mode 100644 index 000000000..57d7518e0 --- /dev/null +++ b/src/scene/block_actions/instance.rs @@ -0,0 +1,289 @@ +//! Saved INSERT evaluation: definition geometry stays immutable; parameter +//! values live in the native per-reference AppDataCache. +use super::*; +use codec::objects::{Dictionary, XRecord, XRecordEntry, XRecordValue}; + +fn entry(doc: &CadDocument, owner: Handle, key: &str) -> Result, &'static str> { + let Some(ObjectType::Dictionary(d)) = doc.objects.get(&owner) else { + return Err("The reference contains an invalid parameter dictionary."); + }; + Ok(d.entries + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, h)| *h)) +} + +fn cache(doc: &CadDocument, insert: Handle, id: i32) -> Result, &'static str> { + let Some(mut owner) = doc.extension_dictionary_handle(insert) else { + return Ok(None); + }; + for key in [ + "AcDbBlockRepresentation", + "AppDataCache", + "ACAD_ENHANCEDBLOCKDATA", + ] { + let Some(next) = entry(doc, owner, key)? else { + return Ok(None); + }; + owner = next; + } + entry(doc, owner, &id.to_string()) +} + +pub(crate) fn current( + doc: &CadDocument, + insert: Handle, + p: &Parameter, +) -> Result { + let Some(h) = cache(doc, insert, p.id)? else { + return Ok(p.end); + }; + let Some(ObjectType::XRecord(r)) = doc.objects.get(&h) else { + return Err("The reference contains an invalid parameter record."); + }; + if !r.entries_complete { + return Err("The reference parameter cache is incomplete."); + } + let points = r + .entries + .iter() + .filter_map(|e| e.value.as_point3d()) + .map(|(x, y, z)| DVec3::new(x, y, z)) + .collect::>(); + if p.xy { + let (Some(a), Some(b)) = (points.first(), points.get(1)) else { + return Err("The XY parameter cache has no finite endpoint."); + }; + if !a.is_finite() + || !b.is_finite() + || (a.x - p.base.x).abs() > 1e-8 + || (b.y - p.base.y).abs() > 1e-8 + || (a.z - p.base.z).abs() > 1e-8 + || (b.z - p.end.z).abs() > 1e-8 + { + return Err("This reference uses an unsupported XY parameter frame."); + } + return Ok(DVec3::new(b.x, a.y, b.z)); + } + let index = usize::from(!p.point); + if !p.point && points.first().is_some_and(|a| a.distance(p.base) > 1e-8) { + return Err("This reference uses a relocated parameter base that is not supported yet."); + } + points + .get(index) + .copied() + .filter(|v| v.is_finite()) + .ok_or("The reference parameter cache has no finite endpoint.") +} + +fn ensure_dictionary( + doc: &mut CadDocument, + owner: Handle, + key: &str, +) -> Result { + if let Some(h) = entry(doc, owner, key)? { + if matches!(doc.objects.get(&h), Some(ObjectType::Dictionary(_))) { + return Ok(h); + } + return Err("The reference contains an invalid parameter dictionary."); + } + let h = doc.allocate_handle(); + let mut d = Dictionary::new(); + d.handle = h; + d.owner = owner; + d.reactors.push(owner); + doc.objects.insert(h, ObjectType::Dictionary(d)); + if let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&owner) { + d.add_entry(key, h); + } + Ok(h) +} + +fn save( + doc: &mut CadDocument, + insert: Handle, + p: &Parameter, + position: DVec3, +) -> Result<(), &'static str> { + let mut owner = doc.ensure_extension_dictionary(insert); + for key in [ + "AcDbBlockRepresentation", + "AppDataCache", + "ACAD_ENHANCEDBLOCKDATA", + ] { + owner = ensure_dictionary(doc, owner, key)?; + } + let key = p.id.to_string(); + let h = if let Some(h) = entry(doc, owner, &key)? { + h + } else { + let h = doc.allocate_handle(); + let mut r = XRecord::named(&key); + r.handle = h; + r.owner = owner; + r.reactors.push(owner); + r.cloning_flags = codec::objects::DictionaryCloningFlags::KeepExisting; + r.entries = vec![ + XRecordEntry::int32(1071, 0), + XRecordEntry::int32(1071, 0), + XRecordEntry::int16(70, 25), + XRecordEntry::int16(70, 104), + ]; + let points = if p.point { + vec![p.end] + } else if p.xy { + vec![ + DVec3::new(p.base.x, p.end.y, p.base.z), + DVec3::new(p.end.x, p.base.y, p.end.z), + -DVec3::Z, + ] + } else if !p.linear { + vec![p.base, p.end, -DVec3::Z, p.end] + } else { + vec![p.base, p.end, -DVec3::Z] + }; + for v in points { + r.entries + .push(XRecordEntry::new(10, XRecordValue::Point3D(v.x, v.y, v.z))); + } + doc.objects.insert(h, ObjectType::XRecord(r)); + if let Some(ObjectType::Dictionary(d)) = doc.objects.get_mut(&owner) { + d.add_entry(&key, h); + } + h + }; + let Some(ObjectType::XRecord(r)) = doc.objects.get_mut(&h) else { + return Err("Invalid parameter record."); + }; + if p.xy { + let mut points = r + .entries + .iter_mut() + .filter(|e| matches!(e.value, XRecordValue::Point3D(..))); + let a = points + .next() + .ok_or("The XY parameter cache has no base point.")?; + a.value = XRecordValue::Point3D(p.base.x, position.y, p.base.z); + let b = points + .next() + .ok_or("The XY parameter cache has no endpoint.")?; + b.value = XRecordValue::Point3D(position.x, p.base.y, position.z); + } else { + let index = usize::from(!p.point); + let e = r + .entries + .iter_mut() + .filter(|e| matches!(e.value, XRecordValue::Point3D(..))) + .nth(index) + .ok_or("The reference parameter cache has no endpoint.")?; + e.value = XRecordValue::Point3D(position.x, position.y, position.z); + } + // Retain unknown metadata and references, but invalidate stale raw bytes. + r.raw_data.clear(); + r.raw_dwg_data = None; + Ok(()) +} + +pub(crate) fn definition(doc: &CadDocument, insert: Handle) -> Option { + let EntityType::Insert(i) = doc.get_entity(insert)? else { + return None; + }; + let s = i.x_scale().abs(); + if s < 1e-12 + || (i.y_scale().abs() - s).abs() > 1e-10 * s.max(1.) + || (i.z_scale().abs() - s).abs() > 1e-10 * s.max(1.) + { + return None; + } + doc.dynamic_definition_for_insert(insert) +} + +/// Stage and validate completely before exposing any changes to the drawing. +pub(crate) fn evaluate( + doc: &CadDocument, + insert: Handle, + parameter: Handle, + local: DVec3, +) -> Result, &'static str> { + let def = + definition(doc, insert).ok_or("Select a uniformly scaled dynamic block reference.")?; + let parameters = parameters(doc, def, Kind::Move); + let selected = parameters + .iter() + .find(|p| p.handle == parameter) + .ok_or("The reference parameter is unavailable.")?; + let mut evaluated = doc.clone(); + for p in ¶meters { + let value = current(doc, insert, p)?; + let Some(ObjectType::DynamicBlock(o)) = evaluated.objects.get_mut(&p.grip) else { + return Err("Missing parameter grip."); + }; + match &mut o.data { + DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => { + g.location = native(value) + } + DynamicBlockData::LinearGrip(g) => g.grip.location = native(value), + _ => return Err("Unsupported parameter grip."), + } + } + if !super::evaluate(&mut evaluated, doc, def, parameter, local)? { + return Ok(None); + } + let value = super::current(&evaluated, selected); + let mut scene = crate::scene::Scene::new(); + scene.document = doc.clone(); + scene.dynamic_representation(insert); + let Some(EntityType::Insert(reference)) = scene.document.get_entity(insert) else { + return Err("Missing block reference."); + }; + let name = reference.block_name.clone(); + if scene + .document + .block_records + .get(&name) + .is_none_or(|b| b.handle == def) + { + return Err("Could not isolate the dynamic block reference."); + } + // Evaluate only temporary definition members, then copy the affected geometry + // into this reference's representation, preserving identity and visibility. + let source = doc + .block_records + .iter() + .find(|b| b.handle == def) + .ok_or("Missing definition.")?; + let target = scene.document.block_records.get(&name).unwrap(); + if source.entity_handles.len() != target.entity_handles.len() { + return Err("The reference topology no longer matches its definition."); + } + let affected = actions(doc, def) + .iter() + .flat_map(|(_, a, _, _)| a.dependencies.iter().copied()) + .collect::>(); + let pairs = source + .entity_handles + .iter() + .copied() + .zip(target.entity_handles.iter().copied()) + .collect::>(); + for (from, to) in pairs { + if !affected.contains(&from) { + continue; + } + let mut e = evaluated + .get_entity(from) + .cloned() + .ok_or("Missing action target.")?; + let target = scene + .document + .get_entity_mut(to) + .ok_or("Missing representation target.")?; + if std::mem::discriminant(&e) != std::mem::discriminant(target) { + return Err("The reference topology no longer matches its definition."); + } + *e.common_mut() = target.common().clone(); + *target = e; + } + save(&mut scene.document, insert, selected, value)?; + Ok(Some(scene.document)) +} diff --git a/src/scene/block_actions/tests.rs b/src/scene/block_actions/tests.rs index 18b14d891..2ffbf4057 100644 --- a/src/scene/block_actions/tests.rs +++ b/src/scene/block_actions/tests.rs @@ -40,6 +40,198 @@ fn start(doc: &CadDocument, h: Handle) -> Vector3 { }; l.start } + +fn instance_line(doc: &CadDocument, insert: Handle) -> Vector3 { + let EntityType::Insert(i) = doc.get_entity(insert).unwrap() else { + panic!() + }; + start( + doc, + doc.block_records.get(&i.block_name).unwrap().entity_handles[0], + ) +} + +#[test] +fn block_actions_saved_xy_polar_cache_metadata_and_rejections() { + for kind in [distance::Kind::XY, distance::Kind::Polar] { + let (mut doc, def, handles) = fixture(); + let p = distance::create( + &mut doc, + def, + kind, + Vector3::new(2., 3., 0.), + Vector3::new(12., 13., 0.), + Vector3::new(5., 6., 0.), + ) + .unwrap(); + create(&mut doc, def, Kind::Move, p, &[handles[0]], DVec3::ZERO).unwrap(); + let a = doc + .add_entity(EntityType::Insert(codec::entities::Insert::new( + "ACTIONS", + Vector3::ZERO, + ))) + .unwrap(); + let original = doc.clone(); + let mut doc = instance::evaluate(&doc, a, p, DVec3::new(20., 18., 0.)) + .unwrap() + .unwrap(); + assert_eq!(instance_line(&doc, a), Vector3::new(9., 5., 0.)); + let parameter = parameters(&doc, def, Kind::Move).remove(0); + assert_eq!( + instance::current(&doc, a, ¶meter).unwrap(), + DVec3::new(20., 18., 0.) + ); + let r = doc + .objects + .values_mut() + .find_map(|o| match o { + ObjectType::XRecord(r) => Some(r), + _ => None, + }) + .unwrap(); + r.entries.push(codec::objects::XRecordEntry::string( + 1, + "retain opaque metadata", + )); + doc = instance::evaluate(&doc, a, p, DVec3::new(22., 21., 0.)) + .unwrap() + .unwrap(); + assert!(doc.objects.values().any(|o|matches!(o,ObjectType::XRecord(r) if r.entries.iter().any(|e|e.value.as_string()==Some("retain opaque metadata"))))); + let reopened = DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap())) + .read() + .unwrap(); + assert_eq!( + instance::current(&reopened, a, ¶meter).unwrap(), + DVec3::new(22., 21., 0.) + ); + assert_eq!(start(&reopened,handles[0]),start(&original,handles[0])); + assert!(instance::evaluate(&doc, a, p, DVec3::splat(f64::NAN)).is_err()); + let mut invalid = doc.clone(); + let EntityType::Insert(i) = invalid.get_entity_mut(a).unwrap() else { + panic!() + }; + i.set_x_scale(2.); + assert!(instance::definition(&invalid, a).is_none()); + assert!(instance::evaluate(&invalid, a, p, DVec3::ONE).is_err()); + } +} + +#[test] +fn block_actions_saved_references_independent_absolute_and_roundtrip() { + let (mut doc, def, handles) = fixture(); + let p = linear(&mut doc, def); + let q = point::create(&mut doc, def, Vector3::ZERO, Vector3::UNIT_Y).unwrap(); + create(&mut doc, def, Kind::Scale, p, &[handles[1]], DVec3::ZERO).unwrap(); + create(&mut doc, def, Kind::Move, q, &[handles[0]], DVec3::ZERO).unwrap(); + let a = doc + .add_entity(EntityType::Insert(codec::entities::Insert::new( + "ACTIONS", + Vector3::ZERO, + ))) + .unwrap(); + let b = doc + .add_entity(EntityType::Insert(codec::entities::Insert::new( + "ACTIONS", + Vector3::new(30., 0., 0.), + ))) + .unwrap(); + let original = doc.clone(); + doc = instance::evaluate(&doc, a, q, DVec3::new(3., 4., 0.)) + .unwrap() + .unwrap(); + doc = instance::evaluate(&doc, a, p, DVec3::new(20., 7., 0.)) + .unwrap() + .unwrap(); + assert_eq!(instance_line(&doc, a), Vector3::new(4., 4., 0.)); + assert_eq!(instance_line(&doc, b), Vector3::UNIT_X); + for h in &handles { + assert_eq!(doc.get_entity(*h), original.get_entity(*h)); + } + for (h, o) in &original.objects { + assert_eq!(doc.objects.get(h), Some(o)); + } + let mut saved = vec![doc.clone()]; + saved.push( + DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap())) + .read() + .unwrap(), + ); + saved.push( + DxfReader::from_reader(Cursor::new(DxfWriter::new(&doc).write_to_vec().unwrap())) + .unwrap() + .read() + .unwrap(), + ); + for doc in saved { + let parameter = parameters(&doc, def, Kind::Move) + .into_iter() + .find(|x| x.handle == p) + .unwrap(); + assert_eq!( + instance::current(&doc, a, ¶meter).unwrap(), + DVec3::new(20., 0., 0.) + ); + let props = doc.dynamic_block_properties(def, Some(a)).unwrap(); + assert!(props + .iter() + .any(|x| x.parameter_handle == p && x.value == BlockEvalValue::Real(20.))); + assert!(instance::evaluate(&doc, a, p, DVec3::new(20., 0., 0.)) + .unwrap() + .is_none()); + let next = instance::evaluate(&doc, a, q, DVec3::new(-2., 1., 0.)) + .unwrap() + .unwrap(); + assert_eq!(instance_line(&next, a), Vector3::new(-1., 1., 0.)); + let EntityType::Insert(i) = next.get_entity(a).unwrap() else { + panic!() + }; + let EntityType::Circle(c) = next + .get_entity( + next.block_records + .get(&i.block_name) + .unwrap() + .entity_handles[1], + ) + .unwrap() + else { + panic!() + }; + assert_eq!(c.radius, 4.); + assert_eq!(instance_line(&next, b), Vector3::UNIT_X); + let resaved = DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&next).unwrap())) + .read() + .unwrap(); + assert_eq!(instance_line(&resaved, a), Vector3::new(-1., 1., 0.)); + let point = parameters(&resaved, def, Kind::Move) + .into_iter() + .find(|x| x.handle == q) + .unwrap(); + assert_eq!( + instance::current(&resaved, a, &point).unwrap(), + DVec3::new(-2., 1., 0.) + ); + // A copied reference may share its evaluated block, but must isolate on edit. + let mut scene = Scene::new(); + scene.document = next.clone(); + let c = scene.copy_entities( + &[a], + &crate::command::EntityTransform::Translate(DVec3::new(40., 0., 0.)), + )[0]; + let copy = instance::evaluate(&scene.document, c, q, DVec3::new(7., 8., 0.)) + .unwrap() + .unwrap(); + assert_eq!(instance_line(©, c), Vector3::new(8., 8., 0.)); + assert_eq!(instance_line(©, a), Vector3::new(-1., 1., 0.)); + assert_eq!( + instance::current(©, c, ¶meter).unwrap(), + DVec3::new(20., 0., 0.) + ); + let reset = instance::evaluate(&next, a, q, DVec3::ZERO) + .unwrap() + .unwrap(); + assert_eq!(instance_line(&reset, a), Vector3::UNIT_X); + } +} #[test] fn block_actions_native_bindings_roundtrip_and_append_preserves_neighbors() { let (mut doc, def, handles) = fixture(); diff --git a/src/scene/block_visibility.rs b/src/scene/block_visibility.rs index 282562974..550e67945 100644 --- a/src/scene/block_visibility.rs +++ b/src/scene/block_visibility.rs @@ -468,6 +468,13 @@ impl Scene { /// again with its current state. Returns the copy's name; a reference /// already showing its own copy, or not dynamic, gets `None`. pub fn visibility_representation(&mut self, insert: Handle) -> Option { + let definition = self.document.dynamic_definition_for_insert(insert)?; + parameter(&self.document, definition)?; + self.dynamic_representation(insert) + } + + /// Isolate an evaluated dynamic reference, including copies sharing a representation. + pub(crate) fn dynamic_representation(&mut self, insert: Handle) -> Option { let Some(EntityType::Insert(reference)) = self.document.get_entity(insert) else { return None; }; @@ -477,7 +484,6 @@ impl Scene { .get(&reference.block_name)? .handle; let definition = self.document.dynamic_definition_for_insert(insert)?; - parameter(&self.document, definition)?; let direct = source == definition; let shared = || { self.document.entities().any(|entity| { @@ -525,18 +531,35 @@ impl Scene { // The reference: AcDbBlockRepresentation / AcDbRepData names the // definition it was evaluated from. let holder = self.document.ensure_extension_dictionary(insert); - let representation = self.document.allocate_handle(); + let existing = match self.document.objects.get(&holder) { + Some(ObjectType::Dictionary(d)) => d + .entries + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("AcDbBlockRepresentation")) + .map(|(_, handle)| *handle), + _ => None, + }; + let representation = existing.unwrap_or_else(|| self.document.allocate_handle()); let data = self.document.allocate_handle(); - let mut dictionary = codec::objects::Dictionary::new(); - dictionary.handle = representation; - dictionary.owner = holder; - dictionary.reactors.push(holder); - dictionary.add_entry("AcDbRepData", data); - self.document - .objects - .insert(representation, ObjectType::Dictionary(dictionary)); - if let Some(ObjectType::Dictionary(holder)) = self.document.objects.get_mut(&holder) { - holder.add_entry("AcDbBlockRepresentation", representation); + if existing.is_none() { + let mut dictionary = codec::objects::Dictionary::new(); + dictionary.handle = representation; + dictionary.owner = holder; + dictionary.reactors.push(holder); + self.document + .objects + .insert(representation, ObjectType::Dictionary(dictionary)); + if let Some(ObjectType::Dictionary(holder)) = self.document.objects.get_mut(&holder) { + holder.add_entry("AcDbBlockRepresentation", representation); + } + } + if let Some(ObjectType::Dictionary(dictionary)) = + self.document.objects.get_mut(&representation) + { + dictionary + .entries + .retain(|(key, _)| !key.eq_ignore_ascii_case("AcDbRepData")); + dictionary.add_entry("AcDbRepData", data); } let mut link = dynamic( data,