Add versioned scripting host model and entity coverage

This commit is contained in:
Felix Riestra committed 2026-09-19 01:13:24 +02:00
1 parent ec2271fc9c
commit 11d5614991
22 files changed
+1015 -4

No files matched your search

+167
View File
@@ -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();
+16
View File
@@ -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();
+10
View File
@@ -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
+3
View File
@@ -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;
+10
View File
@@ -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