forked from 1b-agent/opencadstudio-upstream
Add versioned scripting host model and entity coverage
This commit is contained in:
1 parent
ec2271fc9c
commit
11d5614991
22 files changed
+1015
-4
No files matched your search
@@ -93,6 +93,10 @@ impl<'a> HostSession<'a> {
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let key = (self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch);
|
||||
if self.app.last_plugin_document != Some(key) {
|
||||
v4_support::publish_drawing_changed(key.0, key.1);
|
||||
}
|
||||
v4_support::publish_document_view_v4(self.tab_id(), doc);
|
||||
self.app.last_plugin_document =
|
||||
Some((self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch));
|
||||
@@ -125,6 +129,73 @@ impl<'a> HostSession<'a> {
|
||||
ok
|
||||
}
|
||||
|
||||
pub fn update_entities_transaction(
|
||||
&mut self,
|
||||
label: &str,
|
||||
entities: Vec<EntityType>,
|
||||
) -> Result<(), String> {
|
||||
use std::collections::HashSet;
|
||||
if label.trim().is_empty() { return Err("transaction label is empty".into()); }
|
||||
if entities.is_empty() { return Ok(()); }
|
||||
let mut seen = HashSet::new();
|
||||
for entity in &entities {
|
||||
let handle = entity.common().handle;
|
||||
if handle.is_null() || !seen.insert(handle) {
|
||||
return Err(format!("null or duplicate entity handle: {handle:?}"));
|
||||
}
|
||||
let Some(existing) = self.document().get_entity(handle) else {
|
||||
return Err(format!("entity {handle:?} does not exist"));
|
||||
};
|
||||
if std::mem::discriminant(existing) != std::mem::discriminant(entity) {
|
||||
return Err(format!("entity {handle:?} changes kind"));
|
||||
}
|
||||
if existing.common().owner_handle != entity.common().owner_handle {
|
||||
return Err(format!("entity {handle:?} changes owner"));
|
||||
}
|
||||
ocs_plugin_api::entity_coverage::validate_entity_mutation(existing, entity)
|
||||
.map_err(|error| format!("entity {handle:?}: {error}"))?;
|
||||
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
|
||||
return Err(format!("entity {handle:?} is on a locked layer"));
|
||||
}
|
||||
}
|
||||
self.push_undo(label);
|
||||
for entity in entities {
|
||||
// Validation above makes this infallible while the session owns
|
||||
// the document exclusively.
|
||||
assert!(self.app.tabs[self.tab].scene.update_entity(entity));
|
||||
}
|
||||
self.set_dirty();
|
||||
self.publish_document_view();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn selection(&self) -> Vec<Handle> {
|
||||
self.app.tabs[self.tab].scene.selected_handles_in_order()
|
||||
}
|
||||
|
||||
pub fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for handle in handles {
|
||||
if !seen.insert(*handle) {
|
||||
return Err(format!("duplicate selected entity: {handle:?}"));
|
||||
}
|
||||
if self.document().get_entity(*handle).is_none() {
|
||||
return Err(format!("entity {handle:?} does not exist"));
|
||||
}
|
||||
}
|
||||
if self.selection() == handles { return Ok(()); }
|
||||
let scene = &mut self.app.tabs[self.tab].scene;
|
||||
scene.replace_selection_exact(handles);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if self.tab == self.app.active_tab {
|
||||
self.app.last_plugin_selection = Some((self.tab_id(), self.app.tabs[self.tab].scene.selection_fingerprint()));
|
||||
}
|
||||
crate::plugin::v4_support::publish_selection_changed_v4(self.tab_id(), self.selection());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete the entity with `handle`, keeping the scene's render caches in
|
||||
/// sync. Returns `false` when the entity is absent or on a locked layer
|
||||
/// (which `erase_entities` refuses to remove).
|
||||
@@ -188,6 +259,8 @@ impl<'a> HostSession<'a> {
|
||||
if let Some(ah) = app_handle {
|
||||
xd.raw_dwg_eed.retain(|(a, _)| *a != ah);
|
||||
}
|
||||
self.bump_geometry();
|
||||
self.set_dirty();
|
||||
self.publish_document_view();
|
||||
true
|
||||
}
|
||||
@@ -225,6 +298,8 @@ impl<'a> HostSession<'a> {
|
||||
if let Some(ah) = app_handle {
|
||||
xd.raw_dwg_eed.retain(|(a, _)| *a != ah);
|
||||
}
|
||||
self.bump_geometry();
|
||||
self.set_dirty();
|
||||
self.publish_document_view();
|
||||
true
|
||||
}
|
||||
@@ -322,6 +397,11 @@ impl HostApi for HostSession<'_> {
|
||||
fn update_entity(&mut self, entity: EntityType) -> bool {
|
||||
self.update_entity(entity)
|
||||
}
|
||||
fn update_entities_transaction(&mut self, label: &str, entities: Vec<EntityType>) -> Result<(), String> {
|
||||
self.update_entities_transaction(label, entities)
|
||||
}
|
||||
fn selection(&self) -> Vec<Handle> { self.selection() }
|
||||
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { self.set_selection(handles) }
|
||||
fn remove_entity(&mut self, handle: Handle) -> bool {
|
||||
self.remove_entity(handle)
|
||||
}
|
||||
@@ -489,6 +569,12 @@ impl PluginProcessInteractiveAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PluginProcessInteractiveAdapter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.process.drop_interactive(self.command_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::command::CadCommand for PluginProcessInteractiveAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
"PLUGIN"
|
||||
@@ -559,6 +645,87 @@ mod tests {
|
||||
use acadrust::xdata::XDataValue;
|
||||
use ocs_plugin_api::host::DocumentReader;
|
||||
|
||||
#[test]
|
||||
fn selection_query_is_live_and_replacement_is_validated() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let a = host.add_entity(EntityType::Point(Point::new()));
|
||||
let b = host.add_entity(EntityType::Point(Point::new()));
|
||||
assert!(host.selection().is_empty());
|
||||
host.set_selection(&[b, a]).unwrap();
|
||||
assert_eq!(host.selection(), vec![b, a]);
|
||||
assert!(host.set_selection(&[Handle::new(99999)]).is_err());
|
||||
assert!(host.set_selection(&[a, a]).is_err());
|
||||
assert_eq!(host.selection(), vec![b, a]);
|
||||
host.set_selection(&[]).unwrap();
|
||||
assert!(host.selection().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripted_selection_does_not_expand_linked_leader_annotation() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let annotation = host.add_entity(EntityType::Text(acadrust::entities::Text::default()));
|
||||
let mut leader = acadrust::entities::Leader::default();
|
||||
leader.annotation_handle = annotation;
|
||||
let leader_handle = host.add_entity(EntityType::Leader(leader));
|
||||
host.set_selection(&[leader_handle]).unwrap();
|
||||
assert_eq!(host.selection(), vec![leader_handle]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_transaction_validates_before_mutation_and_undoes_as_one_step() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let (first, second);
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
first = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(1.0, 0.0, 0.0))));
|
||||
second = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(2.0, 0.0, 0.0))));
|
||||
let mut a = host.document().get_entity(first).unwrap().clone();
|
||||
let mut b = host.document().get_entity(second).unwrap().clone();
|
||||
if let EntityType::Point(p) = &mut a { p.location.x = 10.0; }
|
||||
if let EntityType::Point(p) = &mut b { p.location.x = 20.0; }
|
||||
let mut invalid = b.clone();
|
||||
invalid.common_mut().owner_handle = Handle::new(99999);
|
||||
assert!(host.update_entities_transaction("Move points", vec![a.clone(), invalid]).is_err());
|
||||
let mut invalid_geometry = b.clone();
|
||||
if let EntityType::Point(p) = &mut invalid_geometry { p.location.x = f64::NAN; }
|
||||
assert!(host.update_entities_transaction("Move points", vec![a.clone(), invalid_geometry])
|
||||
.unwrap_err().contains("Point.location"));
|
||||
assert_eq!(host.app.tabs[0].history.undo_stack.len(), 0);
|
||||
assert!(matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0));
|
||||
host.update_entities_transaction("Move points", vec![a, b]).unwrap();
|
||||
assert!(matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0));
|
||||
}
|
||||
app.finish_pending_history(0);
|
||||
assert_eq!(app.tabs[0].history.undo_stack.len(), 1);
|
||||
app.undo_steps(1);
|
||||
assert!(matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0));
|
||||
assert!(matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmapped_canvas_kind_layer_change_undoes() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
let handle;
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
handle = host.add_entity(EntityType::Hatch(acadrust::entities::Hatch::default()));
|
||||
let original = host.document().get_entity(handle).unwrap();
|
||||
let changed = ocs_plugin_api::entity_coverage::patch_canvas_layer(original, "HATCHES").unwrap();
|
||||
host.update_entities_transaction("Move hatch layer", vec![changed]).unwrap();
|
||||
assert_eq!(host.document().get_entity(handle).unwrap().common().layer, "HATCHES");
|
||||
}
|
||||
app.finish_pending_history(0);
|
||||
assert_eq!(app.tabs[0].history.undo_stack.len(), 1);
|
||||
app.undo_steps(1);
|
||||
assert_eq!(app.tabs[0].scene.document.get_entity(handle).unwrap().common().layer, "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_variables_change_without_command_reentry() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
|
||||
@@ -276,6 +276,7 @@ impl OpenCADStudio {
|
||||
return;
|
||||
}
|
||||
self.last_plugin_document = Some(key);
|
||||
crate::plugin::v4_support::publish_drawing_changed(tab.id, tab.scene.geometry_epoch);
|
||||
crate::plugin::v4_support::publish_document_view_v4(tab.id, &tab.scene.document);
|
||||
}
|
||||
|
||||
@@ -305,7 +306,22 @@ impl OpenCADStudio {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let command_before = self.tabs.get(self.active_tab).map(|tab| {
|
||||
(tab.id, tab.active_cmd.as_ref().map(|command| command.name().to_owned()))
|
||||
});
|
||||
let task = self.update_inner(msg);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let command_after = self.tabs.get(self.active_tab).map(|tab| {
|
||||
(tab.id, tab.active_cmd.as_ref().map(|command| command.name().to_owned()))
|
||||
});
|
||||
if command_before != command_after {
|
||||
if let Some((tab_id, command)) = command_after {
|
||||
crate::plugin::v4_support::publish_command_state_changed(tab_id, command);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.refresh_gpu_status();
|
||||
self.show_next_startup_modal();
|
||||
self.sync_open_command_history();
|
||||
|
||||
@@ -51,6 +51,16 @@ pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec<acadrust::Handle>)
|
||||
broadcast(HostNotification::SelectionChangedV4 { tab_id, handles });
|
||||
}
|
||||
|
||||
/// Broadcast a change in a tab's active interactive command.
|
||||
pub fn publish_command_state_changed(tab_id: u64, command: Option<String>) {
|
||||
broadcast(HostNotification::CommandStateChanged { tab_id, command });
|
||||
}
|
||||
|
||||
/// Broadcast drawing changes independently of shared-view subscriptions.
|
||||
pub fn publish_drawing_changed(tab_id: u64, epoch: u64) {
|
||||
broadcast(HostNotification::DrawingChanged { tab_id, epoch });
|
||||
}
|
||||
|
||||
/// V4 notification handler installed on the plugin manager.
|
||||
///
|
||||
/// Forwards REPL status messages to the log; other notifications are handled
|
||||
|
||||
@@ -525,6 +525,9 @@ impl Scene {
|
||||
let Some(existing) = self.document.get_entity(handle) else {
|
||||
return false;
|
||||
};
|
||||
// IPC snapshots omit storage-only payloads (notably Unknown/Extended
|
||||
// entities). Preserve them when replacing a clone from a plugin.
|
||||
entity.preserve_storage_data_from(existing);
|
||||
// The caller edited a snapshot copy; keep the live entity in its block.
|
||||
entity.common_mut().owner_handle = existing.common().owner_handle;
|
||||
|
||||
|
||||
@@ -144,6 +144,16 @@ impl Scene {
|
||||
self.bump_selection_set();
|
||||
}
|
||||
|
||||
/// Script-facing replacement: preserve exactly the validated input order.
|
||||
/// UI picks still use `select_entity` and its leader/annotation expansion.
|
||||
pub(crate) fn replace_selection_exact(&mut self, handles: &[Handle]) {
|
||||
if self.selected_handles_in_order() == handles { return; }
|
||||
self.selected_constraint = None;
|
||||
self.selected = handles.iter().copied().collect();
|
||||
self.selected_order = handles.to_vec();
|
||||
self.bump_selection_set();
|
||||
}
|
||||
|
||||
pub fn select_all_visible(&mut self) -> usize {
|
||||
let block = self.interaction_block_handle();
|
||||
let frozen: Option<HashSet<Handle>> = self
|
||||
|
||||
Reference in new issue
Block a user