From 1f552038a8a0d8ee7e5816570a81e9d64a2d8d8a Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:38:53 +0200 Subject: [PATCH 1/6] feat(plugin): reference-only entity designation on the HostApi (API v9) The HostApi gains set_reference_only(handle, reference_only): a plugin can designate display geometry it owns and refreshes. The entity renders like any other, but the built-in edit paths treat it like an entity on a locked layer, while the owning plugin keeps full write access (update, erase, XDATA). - Appended at the trait tail with a refusing default so plugins keep compiling against older hosts; the loader gate range is untouched. - IPC: SetReferenceOnly / SetReferenceOnlyResult round-trip through the shared dispatch, the V3 client and the V4 client, with the shared document cache invalidated on success. - build.rs version_info and manifest API_VERSION bumped to 9 in lockstep. - Evidence: reference_only_request_and_response_roundtrip (bincode round-trip of the request and both response payloads). --- crates/ocs_plugin_api/build.rs | 2 +- crates/ocs_plugin_api/src/host.rs | 29 ++++++++++++++ crates/ocs_plugin_api/src/ipc/client.rs | 17 ++++++++ crates/ocs_plugin_api/src/ipc/protocol.rs | 7 ++++ crates/ocs_plugin_api/src/ipc/server.rs | 6 +++ crates/ocs_plugin_api/src/ipc/v4/client.rs | 17 ++++++++ crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 41 ++++++++++++++++++++ crates/ocs_plugin_api/src/manifest.rs | 7 +++- 8 files changed, 123 insertions(+), 3 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 40901c113..6fba1b930 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -1263,7 +1263,7 @@ fn generate_version_info(out_dir: &Path) { "acadrust_version": opencadcodec.version.to_string(), "acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()), "rustc_version": rustc_version, - "api_version": 8, + "api_version": 9, "api_version_min_supported": 3, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 7e68dc0bf..65e9dd4e7 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -1175,6 +1175,35 @@ pub trait HostApi { fn list_xrecords(&self, _dictionary_path: &[&str]) -> Result, String> { Err("XRecord listing is not supported by this host".to_owned()) } + + // ── Reference-only designation (added in API v9; appended at the end to + // keep vtable indices stable for older plugins) ────────────────────────── + + /// Mark (or unmark) an entity as reference-only (API v9, additive). + /// + /// A reference-only entity is plugin-owned display geometry: it renders + /// normally, but the built-in edit paths treat it like an entity on a + /// locked layer — it is neither grip-editable nor click-pickable (a click + /// passes through to the editable entity underneath), window and box + /// selections skip it, and user-driven writes refuse it. The owning + /// plugin keeps full write access through this API: `update_entity`, + /// [`update_entities_transaction`](HostApi::update_entities_transaction), + /// `remove_entity` and the XDATA record methods keep working, so derived + /// display geometry can be refreshed in place. The designation survives + /// DWG/DXF save and reload. + /// + /// Fails when the entity does not exist or is on a locked layer (the same + /// policy as the XDATA record methods). A call that would not change the + /// current designation succeeds without recording an undo step. + /// + /// Undo composition — the entity-write contract: a change performed + /// inside a plugin-opened [`HostApi::push_undo`] scope joins that scope, + /// so an operation composed of several writes is a single undo step under + /// the plugin's own label. With no scope open the change is one undo step + /// of its own. Refusals record no undo step. + fn set_reference_only(&mut self, _handle: Handle, _reference_only: bool) -> Result<(), String> { + Err("The reference-only designation is not supported by this host".to_owned()) + } } /// Configuration properties for creating or modifying a layer. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 2d7c0e4fb..2f9d09c41 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -692,6 +692,23 @@ impl HostApi for PluginHostApi { Err(error) => Err(error.to_string()), } } + + fn set_reference_only(&mut self, handle: Handle, reference_only: bool) -> Result<(), String> { + match self.client.request(PluginRequest::SetReferenceOnly { + handle, + reference_only, + }) { + Ok(PluginResponse::SetReferenceOnlyResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected reference-only response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } } /// Sentinel reader used when the shared-memory view could not be initialized. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 51f206f0e..44a2f2ec0 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -226,6 +226,11 @@ pub enum PluginRequest { ListXRecords { dictionary_path: Vec, }, + /// V9 (additive): mark an entity reference-only (plugin-owned display data). + SetReferenceOnly { + handle: Handle, + reference_only: bool, + }, } /// Responses the host sends back for `PluginRequest`. @@ -272,6 +277,8 @@ pub enum PluginResponse { XRecordDeleteResult(Result), /// V8 (additive): XRecord names under a dictionary path. XRecordNames(Result, String>), + /// V9 (additive): outcome of the reference-only designation. + SetReferenceOnlyResult(Result<(), String>), } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 874a44ab1..894dc5848 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -151,5 +151,11 @@ pub fn handle_plugin_request( let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect(); PluginResponse::XRecordNames(host.list_xrecords(&path)) } + SetReferenceOnly { + handle, + reference_only, + } => { + PluginResponse::SetReferenceOnlyResult(host.set_reference_only(handle, reference_only)) + } } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 93b080377..aaf2f448f 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -987,6 +987,23 @@ impl HostApi for V4PluginHostApi { Err(error) => Err(error.to_string()), } } + + fn set_reference_only(&mut self, handle: Handle, reference_only: bool) -> Result<(), String> { + match self.request(PluginRequest::SetReferenceOnly { + handle, + reference_only, + }) { + Ok(PluginResponse::SetReferenceOnlyResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected reference-only response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } } #[cfg(test)] diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 450afbf99..cf98d0e63 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -519,6 +519,47 @@ mod tests { } } + #[test] + fn reference_only_request_and_response_roundtrip() { + // The V9 request marks an entity reference-only and the response + // carries the operation's outcome. + let bytes = bincode::serialize(&PluginRequest::SetReferenceOnly { + handle: codec::Handle::new(0x2A), + reference_only: true, + }) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::SetReferenceOnly { handle, reference_only } if + handle == codec::Handle::new(0x2A) && reference_only) + ); + let bytes = bincode::serialize(&PluginRequest::SetReferenceOnly { + handle: codec::Handle::new(0x2A), + reference_only: false, + }) + .unwrap(); + assert!(matches!( + bincode::deserialize::(&bytes).unwrap(), + PluginRequest::SetReferenceOnly { + reference_only: false, + .. + } + )); + let bytes = bincode::serialize(&PluginResponse::SetReferenceOnlyResult(Ok(()))).unwrap(); + assert!(matches!( + bincode::deserialize::(&bytes).unwrap(), + PluginResponse::SetReferenceOnlyResult(Ok(())) + )); + let bytes = bincode::serialize(&PluginResponse::SetReferenceOnlyResult(Err( + "entity is on a locked layer".to_owned(), + ))) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::SetReferenceOnlyResult(Err(error)) if error == "entity is on a locked layer") + ); + } + #[test] fn unknown_notification_deserializes_as_unknown() { // Build a payload with an unknown discriminant (99) followed by some diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 6a40f9ce3..27c371d73 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -24,8 +24,11 @@ /// undoable replacement of a set of existing entities. v8 appends name-addressed /// XRecord object CRUD under named-object-dictionary paths /// (`HostApi::create_xrecord` / `read_xrecord` / `update_xrecord` / -/// `delete_xrecord` / `list_xrecords`). -pub const API_VERSION: u32 = 8; +/// `delete_xrecord` / `list_xrecords`). v9 appends the reference-only entity +/// designation (`HostApi::set_reference_only`): display-only geometry a plugin +/// still owns and refreshes while the built-in edit paths treat it like a +/// locked layer. +pub const API_VERSION: u32 = 9; /// Oldest plugin API major the current host still loads. This keeps previously /// compiled cdylibs usable as long as their vtable layout is a prefix of the From d24adc39b5ad8141844c478edb5c9bcdc43805f5 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:38:53 +0200 Subject: [PATCH 2/6] feat(scene): reference-only entities refuse the edit paths (API v9 host) Host side of HostApi::set_reference_only: plugin-owned display geometry that renders and snaps normally but must not be edited or picked by the person at the screen, while the owning plugin keeps full write access through the host. - Storage: a human-readable marker value in an XDATA record under the host-owned OCS APPID (registered on first use; foreign values survive verbatim; stale verbatim DWG EED is dropped on rewrite), so the flag round-trips DWG and DXF with the entity and cannot collide with plugin APPID namespaces. - Grips: refresh_selected_grips yields none for a reference-only entity and an active grip edit on a newly designated target is cancelled like a locked-layer one. - Picking: a reference-only wire flag extends the snap_only skips in every hit test, so a click passes through to the editable entity underneath, and box, window, lasso, fence and select-all gathers skip the designation; fills, solids and instanced meshes are excluded through the shared candidate handle set. - Write policy: built-in user paths treat a reference-only entity like a locked-layer one (Scene::update_entity / update_entities refuse, erase_entities skips), while the plugin-owned variants (update_entity_plugin / erase_entities_plugin, mirroring the remove_entities editor split) and the whole HostSession surface write through. The MCP entities read exposes a top-level reference_only boolean and set_properties refuses with the reference_only failure code. - Undo: the entity-write contract - a change joins a plugin-opened push_undo scope, a scope-less change is one undo step of its own, refusals and idempotent re-marks record nothing. - Known limit: entities nested inside block instances (the block cache rebuilds instance wires without the flag). Evidence: marking_flags_and_unmarking_clears, foreign_values_under_the_host_app_id_survive, other_apps_are_untouched, reference_only_refusals_record_no_undo_step, reference_only_designation_composes_undo_like_entity_writes, reference_only_survives_dxf_and_dwg_round_trips, user_paths_refuse_and_plugin_paths_write_reference_only_entities, select_all_visible_skips_reference_only_entities, clicks_pass_through_reference_only_wires, box_selections_skip_reference_only_wires, entities_records_expose_the_reference_only_designation. --- src/app/plugin_host.rs | 326 +++++++++++++++++++++++++++++- src/app/properties.rs | 17 +- src/app/record_api.rs | 57 ++++++ src/app/update/viewport.rs | 4 + src/entities/multileader.rs | 1 + src/scene/cache/block_cache.rs | 1 + src/scene/convert/tess.rs | 10 + src/scene/convert/tessellate.rs | 2 + src/scene/entity.rs | 38 +++- src/scene/mod.rs | 33 ++- src/scene/model/wire_model.rs | 7 + src/scene/pick/hit_test.rs | 85 ++++---- src/scene/reference_only.rs | 149 ++++++++++++++ src/scene/reference_only_tests.rs | 109 ++++++++++ src/scene/selection.rs | 27 ++- src/scene/text/complex_lt.rs | 1 + 16 files changed, 806 insertions(+), 61 deletions(-) create mode 100644 src/scene/reference_only.rs create mode 100644 src/scene/reference_only_tests.rs diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 1bfc64064..39718d7dd 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -107,7 +107,7 @@ impl<'a> HostSession<'a> { *slot = attribute; self.app.tabs[self.tab] .scene - .update_entity(EntityType::Insert(insert)) + .update_entity_plugin(EntityType::Insert(insert)) } pub fn document_view(&mut self) -> Option { @@ -373,7 +373,7 @@ impl<'a> HostSession<'a> { insert.attributes.push(attribute); if !self.app.tabs[self.tab] .scene - .update_entity(EntityType::Insert(insert)) + .update_entity_plugin(EntityType::Insert(insert)) { return Handle::NULL; } @@ -415,7 +415,7 @@ impl<'a> HostSession<'a> { } let ok = match entity { EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), - entity => self.app.tabs[self.tab].scene.update_entity(entity), + entity => self.app.tabs[self.tab].scene.update_entity_plugin(entity), }; self.app.sync_registered_layers(self.tab); if ok { @@ -473,7 +473,7 @@ impl<'a> HostSession<'a> { // the document exclusively. let updated = match entity { EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), - entity => self.app.tabs[self.tab].scene.update_entity(entity), + entity => self.app.tabs[self.tab].scene.update_entity_plugin(entity), }; assert!(updated); } @@ -548,7 +548,9 @@ impl<'a> HostSession<'a> { .document() .get_entity(handle) .map(|entity| entity.common().owner_handle); - self.app.tabs[self.tab].scene.erase_entities(&[handle]); + self.app.tabs[self.tab] + .scene + .erase_entities_plugin(&[handle]); let removed = self.document().get_entity(handle).is_none(); if removed { // The document keeps a block record's member list separately from the @@ -1138,7 +1140,9 @@ impl<'a> HostSession<'a> { return Err("the result could not be added".into()); } if delete_source { - self.app.tabs[self.tab].scene.erase_entities(&[source]); + self.app.tabs[self.tab] + .scene + .erase_entities_plugin(&[source]); self.publish_document_view(); } Ok(handle) @@ -1401,7 +1405,9 @@ impl<'a> HostSession<'a> { self.push_undo("Delete layer"); self.document_mut().layers.remove(&name); if !on_layer.is_empty() { - self.app.tabs[self.tab].scene.erase_entities(&on_layer); + self.app.tabs[self.tab] + .scene + .erase_entities_plugin(&on_layer); } self.finish_layer_change(&[name]); Ok(handle) @@ -2256,7 +2262,9 @@ impl<'a> HostSession<'a> { } } if erase_originals { - self.app.tabs[self.tab].scene.erase_entities(&entities); + self.app.tabs[self.tab] + .scene + .erase_entities_plugin(&entities); } self.finish_block_change(); Ok(handle) @@ -3123,6 +3131,74 @@ impl<'a> HostSession<'a> { .collect()) } + /// Mark (or unmark) an entity as reference-only (API v9, additive): + /// plugin-owned display geometry the built-in edit paths treat like a + /// locked-layer entity, while the owning plugin keeps full write access + /// through this session. The designation survives DWG/DXF save and reload. + /// + /// Every refusal — absent entity, locked layer — is decided before the + /// undo scope is touched, so a failure changes nothing. A call that would + /// not change the current designation succeeds without recording an undo + /// step. Undo composition: a change inside a plugin-opened `push_undo` + /// scope joins it (see [`HostSession::ensure_xrecord_undo_scope`]); a + /// scope-less call is one undo step of its own. + pub fn set_reference_only( + &mut self, + handle: Handle, + reference_only: bool, + ) -> Result<(), String> { + // Refusals precede any change (and any undo step). + let Some(entity) = self.document().get_entity(handle) else { + return Err(format!("entity {handle:?} does not exist")); + }; + if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + return Err(format!("entity {handle:?} is on a locked layer")); + } + if crate::scene::reference_only::entity_is_reference_only(entity) == reference_only { + return Ok(()); + } + let own_scope = self.ensure_xrecord_undo_scope("Set Reference-Only"); + // Register the host APPID before the marker is written so the record's + // application is resolvable on save. + if reference_only { + self.ensure_app_id(crate::scene::reference_only::REFERENCE_ONLY_APP_ID); + } + let app_handle = self + .document() + .app_ids + .get(crate::scene::reference_only::REFERENCE_ONLY_APP_ID) + .map(|app| app.handle.value()); + let changed = self + .document_mut() + .get_entity_mut(handle) + .map(|entity| { + let changed = + crate::scene::reference_only::set_entity_reference_only(entity, reference_only); + // Drop stale verbatim EED for the host app so the fresh + // marker — not pre-edit bytes captured on a prior read — + // wins on the next save. + if changed { + if let Some(app_handle) = app_handle { + entity + .common_mut() + .extended_data + .raw_dwg_eed + .retain(|(a, _)| *a != app_handle); + } + } + changed + }) + .unwrap_or(false); + debug_assert!(changed, "designation state was checked before the mutation"); + self.bump_geometry(); + self.set_dirty(); + self.publish_document_view(); + if own_scope { + self.app.finish_pending_history(self.tab); + } + Ok(()) + } + /// Resolve an existing XRecord to its containing dictionary, its validated /// name and its handle, after validating the path, the name and the target /// kind. @@ -3526,6 +3602,9 @@ impl HostApi for HostSession<'_> { fn list_xrecords(&self, dictionary_path: &[&str]) -> Result, String> { self.list_xrecords(dictionary_path) } + fn set_reference_only(&mut self, handle: Handle, reference_only: bool) -> Result<(), String> { + self.set_reference_only(handle, reference_only) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) @@ -13379,6 +13458,237 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) assert!(!host.update_entity(EntityType::Point(ghost))); } + fn reference_test_line(end_x: f64, y: f64, handle: Handle) -> EntityType { + let mut line = codec::entities::Line::from_points( + codec::types::Vector3::new(0.0, y, 0.0), + codec::types::Vector3::new(end_x, y, 0.0), + ); + line.common.handle = handle; + EntityType::Line(line) + } + + #[test] + fn reference_only_refusals_record_no_undo_step() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + // Marking an absent entity refuses. + assert!(host.set_reference_only(Handle::new(999_999), true).is_err()); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + // A locked layer is as unwritable for the designation as for every + // other plugin write. + host.app.tabs[0] + .scene + .document + .layers + .get_mut("0") + .map(|layer| layer.lock()); + assert!(host.set_reference_only(handle, true).is_err()); + host.app.tabs[0] + .scene + .document + .layers + .get_mut("0") + .map(|layer| layer.unlock()); + assert!( + !crate::scene::reference_only::entity_is_reference_only( + host.document().get_entity(handle).unwrap() + ), + "refusals must not partially apply the designation" + ); + assert_eq!( + host.app.tabs[0].history.undo_stack.len(), + 0, + "refusals record no undo step" + ); + // An idempotent re-mark succeeds without recording another step. + host.set_reference_only(handle, true).unwrap(); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), 1); + host.set_reference_only(handle, true).unwrap(); + assert_eq!( + host.app.tabs[0].history.undo_stack.len(), + 1, + "an idempotent re-mark records no undo step" + ); + } + + #[test] + fn reference_only_designation_composes_undo_like_entity_writes() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + // A scope-less designation is one undo step of its own. + host.set_reference_only(handle, true).unwrap(); + host.set_reference_only(handle, false).unwrap(); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), 2); + + // Undo reverts the unmark, redo re-applies it. + drop(host); + app.undo_steps(1); + let mut host = HostSession::new(&mut app, 0); + assert!(host.app.tabs[0].scene.is_reference_only(handle)); + drop(host); + app.redo_steps(1); + let mut host = HostSession::new(&mut app, 0); + assert!(!host.app.tabs[0].scene.is_reference_only(handle)); + + // Inside a plugin-opened push_undo scope the designation joins the + // scope: an entity write plus two markings is ONE step under the + // plugin's label. + host.push_undo("CVI: refresh"); + let preview = host.add_entity(EntityType::Point(Point::at(codec::types::Vector3::new( + 3.0, 0.0, 0.0, + )))); + host.set_reference_only(preview, true).unwrap(); + host.set_reference_only(handle, true).unwrap(); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + assert_eq!(app.tabs[0].history.undo_stack[2].label(), "CVI: refresh"); + + // Undoing the plugin operation reverts it whole. + app.undo_steps(1); + let mut host = HostSession::new(&mut app, 0); + assert!( + host.app.tabs[0] + .scene + .document + .get_entity(preview) + .is_none(), + "undoing the scope removed the entity created inside it" + ); + assert!( + !host.app.tabs[0].scene.is_reference_only(handle), + "undoing the scope restored the designation's prior state" + ); + } + + #[test] + fn reference_only_survives_dxf_and_dwg_round_trips() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + host.set_reference_only(handle, true).unwrap(); + + let flagged = |document: &codec::CadDocument| { + document + .get_entity(handle) + .is_some_and(crate::scene::reference_only::entity_is_reference_only) + }; + // The host APPID the marker lives under travels with the record. + assert!(host.document().app_ids.get("OCS").is_some()); + + let dwg = codec::DwgWriter::write_to_vec(host.document()).unwrap(); + let reloaded = crate::io::load_bytes("reference-only.dwg", dwg).unwrap(); + assert!(flagged(&reloaded), "DWG save/reopen keeps the designation"); + assert!(reloaded.app_ids.get("OCS").is_some()); + + let dxf = codec::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); + let reloaded = crate::io::load_bytes("reference-only.dxf", dxf).unwrap(); + assert!(flagged(&reloaded), "DXF save/reopen keeps the designation"); + assert!(reloaded.app_ids.get("OCS").is_some()); + } + + #[test] + fn user_paths_refuse_and_plugin_paths_write_reference_only_entities() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + host.set_reference_only(handle, true).unwrap(); + let plain = host.add_entity(reference_test_line(10.0, 5.0, Handle::NULL)); + + // Grips: the reference-only entity contributes none; the editable one + // keeps its own. + host.set_selection(&[handle]).unwrap(); + host.app.refresh_selected_grips(); + assert!( + host.app.tabs[0].selected_grips.is_empty(), + "reference-only entities contribute no grips" + ); + host.set_selection(&[plain]).unwrap(); + host.app.refresh_selected_grips(); + assert!(!host.app.tabs[0].selected_grips.is_empty()); + + // The user in-place update refuses; the user batch skips only the + // reference-only member; the plugin-owned variants write through. + assert!(!host.app.tabs[0] + .scene + .update_entity(reference_test_line(20.0, 0.0, handle))); + let applied = host.app.tabs[0].scene.update_entities(vec![ + reference_test_line(20.0, 0.0, handle), + reference_test_line(20.0, 5.0, plain), + ]); + assert_eq!( + applied, 1, + "the user batch applies only the editable entity" + ); + let end_x = |scene: &crate::scene::Scene, h: Handle| match scene.document.get_entity(h) { + Some(EntityType::Line(line)) => line.end.x, + _ => panic!("entity {h:?} must be a line"), + }; + let scene = &host.app.tabs[0].scene; + assert_eq!( + end_x(scene, handle), + 10.0, + "the user paths refused the write" + ); + assert_eq!(end_x(scene, plain), 20.0); + // The plugin-owned variant writes through. A real plugin refresh + // clones the live entity (designation included), edits it, and + // replaces it — so model that flow instead of a bare synthetic. + let mut moved = match host.app.tabs[0].scene.document.get_entity(handle) { + Some(entity) => entity.clone(), + _ => unreachable!("the entity exists"), + }; + if let EntityType::Line(line) = &mut moved { + line.end.x = 30.0; + } + assert!(host.app.tabs[0].scene.update_entity_plugin(moved)); + let scene = &host.app.tabs[0].scene; + assert_eq!( + end_x(scene, handle), + 30.0, + "the plugin-owned update writes through" + ); + assert!( + scene.is_reference_only(handle), + "a refresh-style plugin write preserves the designation" + ); + + // User erase skips reference-only entities; the plugin erase path + // removes them. + host.app.tabs[0].scene.erase_entities(&[handle]); + assert!( + host.app.tabs[0].scene.document.get_entity(handle).is_some(), + "user erase skips reference-only entities" + ); + host.app.tabs[0].scene.erase_entities_plugin(&[handle]); + assert!( + host.app.tabs[0].scene.document.get_entity(handle).is_none(), + "the plugin erase path removes reference-only entities" + ); + assert!(host.app.tabs[0].scene.document.get_entity(plain).is_some()); + } + + #[test] + fn select_all_visible_skips_reference_only_entities() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + host.set_reference_only(handle, true).unwrap(); + let plain = host.add_entity(reference_test_line(10.0, 5.0, Handle::NULL)); + + assert_eq!(host.app.tabs[0].scene.select_all_visible(), 1); + let selected = host.app.tabs[0].scene.selected_handles_in_order(); + assert_eq!(selected, vec![plain]); + } + #[test] fn add_and_update_auto_register_novel_layers_with_real_handles() { // A plugin adds/edits an entity naming a layer no LAYER command ever diff --git a/src/app/properties.rs b/src/app/properties.rs index 80992ee57..7b521ef51 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2661,10 +2661,14 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} /// reachable from Options and from the sysvar of the same name. pub(super) fn refresh_selected_grips(&mut self) { let i = self.active_tab; + // Reference-only targets are cancelled like locked-layer ones: the + // plugin that owns the display geometry may have just designated the + // entity, and a drag that edits it would be overwritten on refresh. let locked_active_grip = self.tabs[i].active_grip.as_ref().is_some_and(|grip| { - grip.targets - .iter() - .any(|target| self.tabs[i].scene.is_layer_locked(target.handle)) + grip.targets.iter().any(|target| { + self.tabs[i].scene.is_layer_locked(target.handle) + || self.tabs[i].scene.is_reference_only(target.handle) + }) }); if locked_active_grip { self.cancel_active_grip_edit(); @@ -2678,7 +2682,8 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); let selected = self.tabs[i].scene.selected_entities(); let single_handle = (selected.len() == 1 - && !self.tabs[i].scene.is_layer_locked(selected[0].0)) + && !self.tabs[i].scene.is_layer_locked(selected[0].0) + && !self.tabs[i].scene.is_reference_only(selected[0].0)) .then(|| selected[0].0); let mut grips = Vec::new(); let mut handles = Vec::new(); @@ -2691,7 +2696,9 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} selected }; for (handle, entity) in selected { - if self.tabs[i].scene.is_layer_locked(handle) { + if self.tabs[i].scene.is_layer_locked(handle) + || self.tabs[i].scene.is_reference_only(handle) + { continue; } let contextual = crate::scene::annotative::entity_for_annotation_context( diff --git a/src/app/record_api.rs b/src/app/record_api.rs index d2b91e272..53e63a807 100644 --- a/src/app/record_api.rs +++ b/src/app/record_api.rs @@ -245,6 +245,8 @@ fn entity_record(entity: &codec::EntityType) -> Result { "record_type".into(), json!(crate::entities::names::dxf_name(entity)), ); + let reference_only = crate::scene::reference_only::entity_is_reference_only(entity); + object.insert("reference_only".into(), json!(reference_only)); Ok(value) } @@ -1506,6 +1508,12 @@ impl OpenCADStudio { if self.tabs[i].scene.is_layer_locked(handle) { return Err(failure("layer_locked", "entity is on a locked layer")); } + if self.tabs[i].scene.is_reference_only(handle) { + return Err(failure( + "reference_only", + "entity is reference-only (plugin-owned display data)", + )); + } let source = self.tabs[i] .scene .document @@ -1692,6 +1700,55 @@ mod tests { app.automation_op(&request.to_string()) } + #[test] + fn entities_records_expose_the_reference_only_designation() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + + let handle = app.tabs[i].scene.add_entity(EntityType::Line( + codec::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ), + )); + fn mark(app: &mut OpenCADStudio, i: usize, handle: Handle, on: bool) { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::reference_only::set_entity_reference_only(entity, on); + } + } + + // The read surface reports the designation alongside the record. + mark(&mut app, i, handle, true); + let records = app.record_query(&json!({"collection":"entities"})); + assert_eq!(records["count"], 1); + assert_eq!( + records["records"][0]["handle"], + json!(format!("{:X}", handle.value())) + ); + assert_eq!(records["records"][0]["reference_only"], json!(true)); + + // The MCP write path treats a reference-only entity like a locked + // layer: the edit is refused, honestly. + let refused = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"entities", + "handle": format!("{:X}", handle.value()), + "updates":[{"path":"/end/x","value":20.0}], + }), + "reference-only", + ); + assert_eq!(refused["ok"], json!(false)); + assert_eq!(refused["code"], json!("reference_only")); + + // Clearing the designation is reported too. + mark(&mut app, i, handle, false); + let records = app.record_query(&json!({"collection":"entities"})); + assert_eq!(records["records"][0]["reference_only"], json!(false)); + } + #[test] fn records_query_and_edit_every_mutable_record_family() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 5aa2108e9..26dd3cac2 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -732,6 +732,9 @@ impl OpenCADStudio { bounds, candidate_handles.as_ref(), )); + // Reference-only entities (API v9) are plugin-owned display geometry: + // a box/window selection must not pick them. + handles.retain(|h| !scene.is_reference_only(*h)); timing.hit = elapsed(t_hit); (handles, timing) } @@ -5168,6 +5171,7 @@ properties={:.1}ms picked={}", .into_iter() .filter_map(|s| Scene::handle_from_wire_name(s)) .filter(|&h| self.tabs[i].scene.passes_selection_filter(h)) + .filter(|&h| !self.tabs[i].scene.is_reference_only(h)) .collect(); if cands.len() >= 2 { // Overlap: open the list box at the cursor. diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index cf474df48..0e6c4dffe 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -2344,6 +2344,7 @@ impl MultiLeaderTess for MultiLeader { depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index c82fe304c..a7d2e648f 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1758,6 +1758,7 @@ impl Batches { depth_override: b.local_depth, display_visible: !b.hide_unselected || selected, snap_only: false, + reference_only: false, plot_visible: b.plot_visible, fill_is_3d: b.fill_is_3d, fill_is_2d_solid: b.fill_is_2d_solid, diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 1edecafdd..a2e5e384a 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -547,6 +547,16 @@ pub(crate) fn tessellate_entity( } } + // Reference-only entities (API v9) render and snap normally but must not + // be picked by the built-in edit paths; the pick-side wire filters in + // `scene::pick::hit_test` and the selection-gather retains consume the + // flag. + if crate::scene::reference_only::entity_is_reference_only(e) { + for wire in &mut wires { + wire.reference_only = true; + } + } + wires } fn tessellate_entity_inner( diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index ff9e2b0cb..89bac4f8e 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -1808,6 +1808,7 @@ pub fn tessellate( depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, @@ -1973,6 +1974,7 @@ pub fn tessellate( depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index df09813c2..880517d0a 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -519,23 +519,47 @@ impl Scene { /// existing entity. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn update_entity(&mut self, entity: EntityType) -> bool { - let mut changed = Vec::with_capacity(1); - let mut block_change = false; - if !self.update_entity_impl(entity, &mut changed, &mut block_change) { - return false; - } - self.publish_entity_updates(changed, block_change); - true + self.update_entities_policy(vec![entity], false) == 1 } /// Batched variant of [`update_entity`]: writes every replacement, then /// issues a single geometry bump for the whole set instead of one bump per /// entity. pub fn update_entities(&mut self, updates: Vec) -> usize { + self.update_entities_policy(updates, false) + } + + /// Like [`update_entity`](Self::update_entity) but without the + /// reference-only refusal. The plugin owns reference-only entities and + /// keeps full write access through the plugin host API, so a plugin's own + /// refresh of derived display geometry must not be refused. Locked-layer + /// policy stays enforced either way. + pub fn update_entity_plugin(&mut self, entity: EntityType) -> bool { + self.update_entities_policy(vec![entity], true) == 1 + } + + /// Like [`update_entities`](Self::update_entities) but without the + /// reference-only refusal (see [`update_entity_plugin`](Self::update_entity_plugin)). + pub fn update_entities_plugin(&mut self, updates: Vec) -> usize { + self.update_entities_policy(updates, true) + } + + /// Shared write path of the four `update_entity*` entry points. Locked + /// layers always refuse (inside `update_entity_impl`); reference-only + /// entities refuse every caller except the plugin-owned `_plugin` + /// variants, mirroring the `remove_entities` edit-policy split. + fn update_entities_policy( + &mut self, + updates: Vec, + allow_reference_only: bool, + ) -> usize { let mut moved = 0usize; let mut changed = Vec::with_capacity(updates.len()); let mut block_change = false; for entity in updates { + if !allow_reference_only && self.is_reference_only(entity.common().handle) { + continue; + } if self.update_entity_impl(entity, &mut changed, &mut block_change) { moved += 1; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 85a819e1e..779a46109 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -31,6 +31,8 @@ pub enum BlockScalePolicy { mod block_base_point_tests; mod boundary; mod camera_ops; +#[cfg(test)] +mod reference_only_tests; pub use camera_ops::{add_background, copy_background, erase_background}; pub(crate) mod centerline; pub(crate) mod centermark; @@ -56,6 +58,7 @@ mod page_setup; mod paper; mod preview; mod project; +pub mod reference_only; mod scene_markers; mod selection; pub mod viewport_dimension_pick; @@ -9145,6 +9148,16 @@ impl Scene { .is_some_and(|l| l.is_locked()) } + /// True when `handle` addresses a reference-only entity (API v9): + /// plugin-owned display geometry the built-in edit paths treat like a + /// locked-layer entity while its owning plugin keeps write access through + /// the plugin host API. Absent entities are never reference-only. + pub fn is_reference_only(&self, handle: Handle) -> bool { + self.document + .get_entity(handle) + .is_some_and(reference_only::entity_is_reference_only) + } + /// The name of the locked layer `handle` sits on, if any (for messages). pub fn locked_layer_name(&self, handle: Handle) -> Option { let name = self @@ -10398,6 +10411,21 @@ impl Scene { pub fn interaction_candidate_handles( &self, candidates: &crate::scene::pick::interaction_index::InteractionCandidates, + ) -> Option> { + let handles = self.interaction_candidate_handles_all(candidates); + // Reference-only entities (API v9) are plugin-owned display geometry: + // their fills, solids and instanced meshes are not pickable, so the + // click/hover fallback paths that consume this set skip them and a + // click passes through to the editable entity underneath. + handles.map(|mut handles| { + handles.retain(|h| !self.is_reference_only(*h)); + handles + }) + } + + fn interaction_candidate_handles_all( + &self, + candidates: &crate::scene::pick::interaction_index::InteractionCandidates, ) -> Option> { let mut handles: HashSet = candidates .iter() @@ -10643,7 +10671,7 @@ impl Scene { candidate_handles.as_ref(), )); } - handles.retain(|&h| self.passes_selection_filter(h)); + handles.retain(|&h| self.passes_selection_filter(h) && !self.is_reference_only(h)); handles } @@ -12733,7 +12761,8 @@ vis_index={:.1} visible_probe={:.1}", } /// LAYOUTCREATEVIEWPORT: a layout opened for the first time gets a viewport. -static LAYOUT_CREATE_VIEWPORT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); +static LAYOUT_CREATE_VIEWPORT: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(true); pub fn layout_create_viewport() -> bool { LAYOUT_CREATE_VIEWPORT.load(std::sync::atomic::Ordering::Relaxed) diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 8ee9fbc0c..2474a490b 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -822,6 +822,11 @@ pub struct WireModel { /// (`display_visible` false) it is what object snaps find; drawn, it is /// display only and snaps skip it too ([`Self::is_display_only`]). pub snap_only: bool, + /// Plugin-owned reference-only source (API v9): the entity is display + /// geometry the built-in edit paths must not pick — clicks fall through + /// to the editable entity underneath. Rendering and snapping are + /// unaffected. + pub reference_only: bool, /// Whether this wire is included in plotted output. pub plot_visible: bool, /// `true` when [`fill_tris`] is a real 3-D surface (PolyfaceMesh / @@ -907,6 +912,7 @@ impl WireModel { depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, @@ -1444,6 +1450,7 @@ impl Default for WireModel { depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 50a776218..59d884e73 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -225,7 +225,10 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let mut best_dist = f32::MAX; let mut best: Option<&str> = None; - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { let tolerance = pick_tolerance_px(wire, lw_display, base_radius_px); let mut previous = None; for (index, point) in wire.points.iter().enumerate() { @@ -266,7 +269,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -299,7 +302,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } else { // Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain. for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -354,7 +357,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if let Some(depth) = triangle_ref_hit_depth( @@ -373,7 +376,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.fill_tris.is_empty() { @@ -412,7 +415,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if let Some(depth) = triangle_ref_hit_depth( @@ -431,7 +434,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.pick_tris.is_empty() { @@ -475,7 +478,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let start = glyph.start as usize; @@ -491,7 +494,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.text_verts.is_empty() { @@ -526,7 +529,10 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } let mut hits: Vec<(f32, &str)> = Vec::new(); let mut marker_hits: HashMap<&str, f32> = HashMap::default(); - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { let tolerance = pick_tolerance_px(wire, lw_display, base_radius_px); let mut previous = None; for (index, point) in wire.points.iter().enumerate() { @@ -563,7 +569,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -600,7 +606,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( })); } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -646,7 +652,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if hits.iter().any(|&(_, name)| name == wire.name) { @@ -670,7 +676,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.fill_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { @@ -706,7 +712,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if hits.iter().any(|&(_, name)| name == wire.name) { @@ -730,7 +736,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.pick_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { @@ -762,7 +768,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if hits.iter().any(|&(_, name)| name == wire.name) { @@ -780,7 +786,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.text_verts.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { @@ -1324,7 +1330,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( // path below run identical code. let hit_name = |segment: &SegmentRef| -> Option<&'a str> { let wire = wires.source_wire(segment.wire)?; - if wire.snap_only { + if wire.snap_only || wire.reference_only { return None; } let start = segment.start as usize; @@ -1369,7 +1375,10 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( } } } - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { if !seen.contains(wire.name.as_str()) && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| segment_hits(a, b)) && seen.insert(wire.name.as_str()) @@ -1385,7 +1394,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if already(&wire_hit, triangle.wire) { @@ -1412,7 +1421,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if already(&wire_hit, glyph.wire) { @@ -1435,7 +1444,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( // Degenerate point-only wires have no indexed segment or surface primitive. for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.points.len() >= 2 @@ -1494,7 +1503,7 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let start = segment.start as usize; @@ -1517,7 +1526,10 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>( out.push(wire.name.as_str()); } } - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { if !seen.contains(wire.name.as_str()) && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| cuts(a, b)) && seen.insert(wire.name.as_str()) @@ -1528,7 +1540,7 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>( return out; } for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.points.len() < 2 { @@ -1569,7 +1581,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let start = segment.start as usize; @@ -1596,7 +1608,10 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( out.push(wire.name.as_str()); } } - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { if !seen.contains(wire.name.as_str()) && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| { point_in_polygon(a, poly) @@ -1616,7 +1631,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if seen.contains(wire.name.as_str()) { @@ -1641,7 +1656,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if seen.contains(wire.name.as_str()) { @@ -1662,7 +1677,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( } } for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.points.len() >= 2 @@ -1738,7 +1753,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( let mut out = Vec::new(); let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } // Fallback: when wire has no line geometry (e.g. greek text emits @@ -1826,7 +1841,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let name = wire.name.as_str(); @@ -1939,7 +1954,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( let mut out = Vec::new(); let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } // Same AABB fallback as `box_hit`: when a wire has no line @@ -2029,7 +2044,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let aabb_pts: Vec<[f32; 3]>; diff --git a/src/scene/reference_only.rs b/src/scene/reference_only.rs new file mode 100644 index 000000000..7ecfe93c9 --- /dev/null +++ b/src/scene/reference_only.rs @@ -0,0 +1,149 @@ +//! Reference-only designation (API v9). +//! +//! A reference-only entity is plugin-owned display geometry: it renders and +//! snaps normally, but the built-in edit paths treat it like an entity on a +//! locked layer — no grips, no click picking (a click passes through to the +//! editable entity underneath), and user-driven writes refuse it. The owning +//! plugin keeps full write access through the plugin host API, so it can +//! refresh derived geometry in place. +//! +//! The designation persists as a marker value inside an XDATA record under a +//! host-owned APPID, so it round-trips DWG and DXF with the entity and never +//! collides with plugin APPID namespaces (plugins register their own +//! application names through their manifest). + +use codec::entities::EntityType; +use codec::xdata::{ExtendedDataRecord, XDataValue}; + +/// Host-owned APPID carrying the designation. The name belongs to the host +/// alone; plugin records live under the plugin's own registered APPIDs. +pub const REFERENCE_ONLY_APP_ID: &str = "OCS"; + +/// Marker value (DXF group code 1000) that flags an entity reference-only. +/// Human-readable so an exported file explains itself. +const REFERENCE_ONLY_MARKER: &str = "REFERENCE_ONLY"; + +/// True when `value` is the reference-only marker. +fn is_marker(value: &XDataValue) -> bool { + matches!(value, XDataValue::String(text) if text.as_str() == REFERENCE_ONLY_MARKER) +} + +/// True when `entity` carries the reference-only designation. Tolerant read: +/// a record without the marker (foreign or damaged content) is not +/// reference-only. +pub fn entity_is_reference_only(entity: &EntityType) -> bool { + let Some(record) = entity + .common() + .extended_data + .get_record(REFERENCE_ONLY_APP_ID) + else { + return false; + }; + record.values.iter().any(is_marker) +} + +/// Set (or clear) the designation on `entity`, returning whether the entity +/// changed. Only the marker value is added or removed; foreign values under +/// the host APPID are preserved verbatim, and a record left without values is +/// dropped so a cleared flag leaves no empty record behind. +pub fn set_entity_reference_only(entity: &mut EntityType, reference_only: bool) -> bool { + if entity_is_reference_only(entity) == reference_only { + return false; + } + let xd = &mut entity.common_mut().extended_data; + if reference_only { + let mut record = xd + .get_record(REFERENCE_ONLY_APP_ID) + .cloned() + .unwrap_or_else(|| ExtendedDataRecord::new(REFERENCE_ONLY_APP_ID)); + record + .values + .push(XDataValue::String(REFERENCE_ONLY_MARKER.to_owned())); + xd.upsert_record(record); + } else { + let Some(mut record) = xd.get_record(REFERENCE_ONLY_APP_ID).cloned() else { + return false; + }; + record.values.retain(|value| !is_marker(value)); + if record.values.is_empty() { + xd.remove_record(REFERENCE_ONLY_APP_ID); + } else { + xd.upsert_record(record); + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use codec::entities::Line; + + fn entity() -> EntityType { + EntityType::Line(Line::new()) + } + + #[test] + fn marking_flags_and_unmarking_clears() { + let mut entity = entity(); + assert!(!entity_is_reference_only(&entity)); + assert!(set_entity_reference_only(&mut entity, true)); + assert!(entity_is_reference_only(&entity)); + // Idempotent: a second mark changes nothing and reports it. + assert!(!set_entity_reference_only(&mut entity, true)); + assert!(set_entity_reference_only(&mut entity, false)); + assert!(!entity_is_reference_only(&entity)); + assert!(!set_entity_reference_only(&mut entity, false)); + // A cleared flag leaves no empty record behind. + assert!(entity + .common() + .extended_data + .get_record(REFERENCE_ONLY_APP_ID) + .is_none()); + } + + #[test] + fn foreign_values_under_the_host_app_id_survive() { + let mut entity = entity(); + let mut record = ExtendedDataRecord::new(REFERENCE_ONLY_APP_ID); + record.values.push(XDataValue::Integer16(7)); + entity.common_mut().extended_data.add_record(record); + // A record without the marker is not reference-only. + assert!(!entity_is_reference_only(&entity)); + assert!(set_entity_reference_only(&mut entity, true)); + let record = entity + .common() + .extended_data + .get_record(REFERENCE_ONLY_APP_ID) + .expect("record exists"); + assert_eq!( + record.values, + vec![ + XDataValue::Integer16(7), + XDataValue::String(REFERENCE_ONLY_MARKER.to_owned()), + ] + ); + assert!(set_entity_reference_only(&mut entity, false)); + let record = entity + .common() + .extended_data + .get_record(REFERENCE_ONLY_APP_ID) + .expect("record survives"); + assert_eq!(record.values, vec![XDataValue::Integer16(7)]); + } + + #[test] + fn other_apps_are_untouched() { + let mut entity = entity(); + assert!(set_entity_reference_only(&mut entity, true)); + let mut foreign = ExtendedDataRecord::new("CVIFC"); + foreign.values.push(XDataValue::String("road".to_owned())); + entity.common_mut().extended_data.add_record(foreign); + assert!(set_entity_reference_only(&mut entity, false)); + assert!(entity + .common() + .extended_data + .get_record("CVIFC") + .is_some_and(|record| record.values.len() == 1)); + } +} diff --git a/src/scene/reference_only_tests.rs b/src/scene/reference_only_tests.rs new file mode 100644 index 000000000..5127d6144 --- /dev/null +++ b/src/scene/reference_only_tests.rs @@ -0,0 +1,109 @@ +//! Pick-side tests for the reference-only designation (API v9): clicks pass +//! through reference-only wires and box selections skip them. + +use crate::scene::model::wire_model::WireModel; +use crate::scene::pick::hit_test; +use crate::scene::Scene; +use codec::Handle; +use glam::{DVec3, Mat4}; +use iced::{Point, Rectangle, Size}; + +/// A view whose world coordinates map one-to-one onto screen coordinates +/// (the convention of `block_base_point_tests::WorldAsScreen`). +struct WorldAsScreen { + view_rot: Mat4, + eye: DVec3, + bounds: Rectangle, +} + +impl WorldAsScreen { + fn new(extent: f32) -> Self { + Self { + view_rot: Mat4::from_scale(glam::Vec3::new(2.0 / extent, -2.0 / extent, 1.0)), + eye: DVec3::new(extent as f64 / 2.0, extent as f64 / 2.0, 0.0), + bounds: Rectangle::new(Point::ORIGIN, Size::new(extent, extent)), + } + } + + fn at(&self, point: [f64; 3]) -> Point { + Point::new(point[0] as f32, point[1] as f32) + } +} + +fn segment_wire(name: &str, from: [f64; 3], to: [f64; 3], reference_only: bool) -> WireModel { + WireModel { + name: name.to_owned(), + points: vec![ + [from[0] as f32, from[1] as f32, from[2] as f32], + [to[0] as f32, to[1] as f32, to[2] as f32], + ], + reference_only, + ..WireModel::default() + } +} + +fn click_at(point: [f64; 3], view: &WorldAsScreen, wires: &[WireModel]) -> Option { + hit_test::click_hit( + view.at(point), + wires, + view.view_rot, + view.eye, + view.bounds, + false, + 8.0, + &rustc_hash::FxHashMap::default(), + ) + .and_then(Scene::handle_from_wire_name) +} + +#[test] +fn clicks_pass_through_reference_only_wires() { + let view = WorldAsScreen::new(100.0); + let editable = segment_wire("1", [25.0, 25.0, 0.0], [75.0, 25.0, 0.0], false); + let reference = segment_wire("2", [25.0, 30.0, 0.0], [75.0, 30.0, 0.0], true); + let wires = [editable, reference]; + + // A click sitting exactly on the reference-only wire passes through to + // the editable wire 5 px away (inside the 8 px aperture). + assert_eq!( + click_at([50.0, 30.0, 0.0], &view, &wires), + Some(Handle::new(1)), + "a click on reference-only geometry must resolve to the entity underneath" + ); + assert_eq!( + click_at([50.0, 25.0, 0.0], &view, &wires), + Some(Handle::new(1)) + ); + + // With only the reference-only wire under the cursor the click misses. + let reference_only = [wires[1].clone()]; + assert_eq!(click_at([50.0, 30.0, 0.0], &view, &reference_only), None); + assert_eq!(click_at([50.0, 25.0, 0.0], &view, &reference_only), None); +} + +#[test] +fn box_selections_skip_reference_only_wires() { + let view = WorldAsScreen::new(100.0); + let editable = segment_wire("1", [25.0, 25.0, 0.0], [75.0, 25.0, 0.0], false); + let reference = segment_wire("2", [25.0, 30.0, 0.0], [75.0, 30.0, 0.0], true); + let wires = [editable, reference]; + + let hits = hit_test::box_hit( + view.at([10.0, 10.0, 0.0]), + view.at([90.0, 50.0, 0.0]), + false, + &wires, + view.view_rot, + view.eye, + view.bounds, + ); + let handles: Vec = hits + .into_iter() + .filter_map(Scene::handle_from_wire_name) + .collect(); + assert_eq!( + handles, + vec![Handle::new(1)], + "a box selection must not pick reference-only geometry" + ); +} diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 11b2a67d6..95dc55353 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -203,6 +203,7 @@ impl Scene { let selected = handles .into_iter() .filter(|handle| self.passes_selection_filter(*handle)) + .filter(|handle| !self.is_reference_only(*handle)) .filter(|handle| { self.document.get_entity(*handle).is_some_and(|entity| { self.resident_entity_visible( @@ -1059,7 +1060,15 @@ impl Scene { // ── Erase ───────────────────────────────────────────────────────────── pub fn erase_entities(&mut self, handles: &[Handle]) { - self.remove_entities(handles, true); + self.remove_entities(handles, true, true); + } + + /// Like [`erase_entities`](Self::erase_entities) but without the + /// reference-only skip. The plugin owns reference-only entities and + /// removes them through the plugin host API; locked-layer policy stays + /// enforced either way. + pub fn erase_entities_plugin(&mut self, handles: &[Handle]) { + self.remove_entities(handles, true, false); } /// Roll back entities created by the current command after a downstream @@ -1068,10 +1077,15 @@ impl Scene { /// The shared removal path keeps first-touch undo state, history objects, /// groups, selection, and derived caches coherent. pub(crate) fn rollback_new_entities(&mut self, handles: &[Handle]) { - self.remove_entities(handles, false); + self.remove_entities(handles, false, false); } - fn remove_entities(&mut self, handles: &[Handle], respect_layer_locks: bool) { + fn remove_entities( + &mut self, + handles: &[Handle], + respect_layer_locks: bool, + respect_reference_only: bool, + ) { let erase_handles = self.handles_expanded_for_leader_annotations(handles); // One pass over document.objects instead of a scan per erased handle. @@ -1095,10 +1109,15 @@ impl Scene { let mut hover_changed = false; for &h in &erase_handles { - // Objects on a locked layer can't be erased. + // Objects on a locked layer can't be erased. Reference-only + // entities are plugin-owned: only the plugin-owned erase path + // may remove them. if respect_layer_locks && self.is_layer_locked(h) { continue; } + if respect_reference_only && self.is_reference_only(h) { + continue; + } let settings_handle = self.document.get_entity(h).and_then(|entity| match entity { EntityType::Extended(extended) => match &extended.data { codec::entities::ExtendedEntityData::SectionObject(data) diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index 56a3c6f92..49587059a 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -382,6 +382,7 @@ pub fn apply_along( depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, From 856b7bc8a4c598e1661c1692c14d72ad3c2b24d2 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:38:53 +0200 Subject: [PATCH 3/6] docs(plugin): reference-only entities (API v9) Document the designation in the host-model capability table and its own section, and record the increment in the completion-plan ledger with the storage decision, the behaviour split and the test evidence. --- docs/plugin-host-model-completion-plan.md | 38 +++++++++++++++++++++++ docs/plugin-host-model.md | 28 ++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 00d437ab9..7ded4cf43 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -362,3 +362,41 @@ in one scope = one step, undone and redone whole), `xrecords_survive_dxf_and_dwg_round_trips` (typed entries reload from both writers), the IPC round-trips `xrecord_request_and_response_roundtrip` and `xrecord_requests_await_their_typed_responses`. The API is host-level and stands on its own; a Python mapping (`doc.xrecords`-style) is future work. + +**Reference-only entities (2026-10-13, API v9).** The host gained a per-entity "reference-only" +designation for plugin-owned display geometry: `HostApi::set_reference_only` marks (or unmarks) an +entity as display data the person at the screen must not edit, while the owning plugin keeps full +write access. Behaviour split: a reference-only entity renders and snaps normally but is not +grip-editable (`refresh_selected_grips` yields nothing for it and an active grip edit on a newly +designated target is cancelled like a locked-layer one), not click-pickable (the pick-side wire +flag skips it, so a click passes through to the editable entity underneath), and skipped by box, +window, lasso, fence and select-all selection gathers. Built-in user write paths treat it like a +locked-layer entity — `Scene::update_entity` / `update_entities`, `erase_entities`, MCP +`set_properties` and modify machinery all refuse or skip it — while the plugin-owned variants +(`Scene::update_entity_plugin` / `erase_entities_plugin`, mirroring the +`remove_entities(handles, respect_layer_locks)` editor split) and the whole `HostSession` surface +(`add_entity`, `update_entity`, `update_entities_transaction`, `remove_entity`, XDATA records, +layer/block table operations) write through, so a preview refresh never needs unlock/relock cycles. +Locks still apply to the plugin: marking and clearing refuse absent entities and locked layers, +and every refusal is decided before the undo scope is touched. Undo is the entity-write contract: +a change inside a plugin-opened `push_undo` scope joins it (entity writes plus designations are one +step under the plugin's label); a scope-less change is one undo step of its own; an idempotent +re-designation records nothing. Storage: a human-readable marker value in an XDATA record under the +host-owned `OCS` APPID (registered on first use, one record per application per entity, foreign +values preserved verbatim; stale verbatim DWG EED is dropped on rewrite), so the flag round-trips +DWG and DXF save/reload with the entity and cannot collide with plugin APPID namespaces — chosen +over a codec common-data bit because the codec is a pinned external dependency. The MCP `entities` +read exposes the flag as a top-level `reference_only` boolean on every entity record. Known limit: +entities nested inside block instances (the block cache rebuilds instance wires without the flag) — +reference-only targets top-level geometry. Evidence: the storage unit tests +`marking_flags_and_unmarking_clears`, `foreign_values_under_the_host_app_id_survive`, +`other_apps_are_untouched`; the host behaviour tests `reference_only_refusals_record_no_undo_step`, +`reference_only_designation_composes_undo_like_entity_writes` (own step, scope join, whole-scope +undo), `reference_only_survives_dxf_and_dwg_round_trips` (both writers, APPID table included), +`user_paths_refuse_and_plugin_paths_write_reference_only_entities` (grips suppressed, user +update/erase refused, plugin update/erase through, designation survives a refresh-style replace), +`select_all_visible_skips_reference_only_entities`; the pick tests `clicks_pass_through_reference_only_wires` +and `box_selections_skip_reference_only_wires`; the record surface test +`entities_records_expose_the_reference_only_designation`; and the IPC round-trip +`reference_only_request_and_response_roundtrip`. Like the XRecord API, a Python mapping is future +work. diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index e1e42d3f1..8df606e5d 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -22,7 +22,7 @@ without it keeps working, and the default `HostApi` methods refuse. | Area | `HostApi` entry | What it does | |---|---|---| | Read | `document()`, `entity_snapshot` | The full `CadDocument` snapshot; a detached JSON view of any typed entity. | -| Entities | `add_entity`, `remove_entity`, `update_entities_transaction` | Create, delete and replace entities. A transaction validates every replacement first and either commits all of them as one undo step or none. | +| Entities | `add_entity`, `remove_entity`, `update_entities_transaction`, `set_reference_only` | Create, delete and replace entities. A transaction validates every replacement first and either commits all of them as one undo step or none. `set_reference_only` designates plugin-owned display geometry (API v9, see below). | | Selection | `selection`, `set_selection` | Ordered, tab-scoped, exact (missing or duplicate handles reject the request). | | Solids | `solid_operation` | Kernel-backed create, transform, booleans, extrude, region and surface from a profile, picture embedding. Every result is verified to lift back from its ACIS payload without loss; a refusal leaves the operands untouched. | | Tables | `table_operation` | Layers, text and dimension styles, blocks and their contents, linetypes, layouts (see below). | @@ -105,6 +105,32 @@ objects map byte-exactly, the auto-created dictionaries included. dictionary's own order, so audits need no prior knowledge. Not yet mapped to Python. +## Reference-only entities (API v9) + +`set_reference_only` designates plugin-owned display geometry: a +reference-only entity renders and snaps normally, but the built-in edit paths +treat it like an entity on a locked layer — it is not grip-editable, not +click-pickable (a click passes through to the editable entity underneath), +and window, box, lasso and fence selections skip it. User-driven writes +(grip drags, modify commands, the properties panel, user erase, MCP +`set_properties`) refuse it; the owning plugin keeps full write access: +`add_entity`, `update_entity`, `update_entities_transaction`, `remove_entity` +and the XDATA record methods are unaffected, so a plugin can refresh derived +display geometry in place. The record-method policy applies first: absent +entities and locked layers refuse, and like them every refusal is decided +before the undo scope is touched, so a failure changes nothing. A +re-designation that would not change the state succeeds without recording an +undo step. Undo composition is the entity-write contract: a change inside a +plugin-opened `push_undo` scope joins it, so an operation composed of several +writes is a single undo step under the plugin's label; with no scope open a +change is one undo step of its own. The designation survives DWG and DXF +save/reload, stored as a marker value in an XDATA record under the host-owned +`OCS` application namespace — plugins register their own APPIDs through their +manifest, so the two cannot collide. Out of scope today: entities nested +inside block instances (the block cache rebuilds instance wires without the +flag) and a Python mapping. The MCP `entities` read exposes the flag as a +`reference_only` boolean on every entity record. + ## Commands `run_command` takes a `CommandRequest` (`Run`, `Start`, `Point`, `Text`, `Token`, From 06ee0798a8925fbbc01c069eea75df6cf309c576 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:15:00 +0200 Subject: [PATCH 4/6] docs(plugin): reference-only usage example for plugin authors Adds a 'Usage from a plugin' subsection to the v9 section: build + designate inside one push_undo scope; refresh in place via clone -> edit -> replace (the designation travels with the entity); refusal and idempotency rules; and the snapshot-path note that out-of-process plugins resolve entities by iterating document().entities() (a received DocumentSnapshot does not serve get_entity lookups). --- docs/plugin-host-model.md | 46 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 8df606e5d..2c1cd2bec 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -131,6 +131,52 @@ inside block instances (the block cache rebuilds instance wires without the flag) and a Python mapping. The MCP `entities` read exposes the flag as a `reference_only` boolean on every entity record. +### Usage from a plugin + +Build the derived geometry and designate it inside one `push_undo` scope, so +creating a preview plus marking it is a single undo step under the plugin's +label: + +```rust +host.push_undo("CVI: refresh path preview"); +let preview = host.add_entity(EntityType::LwPolyline(computed_path)); +if let Err(error) = host.set_reference_only(preview, true) { + host.push_error(&format!("set_reference_only failed: {error}")); +} +``` + +Refresh the display data in place through the same API — reads before the +scope, mutations inside it (the entity-write contract's ordering rule): + +```rust +let found = host + .document() + .entities() + .find(|entity| entity.common().handle == preview) + .cloned(); +if let Some(mut entity) = found { + // edit the clone; the designation travels with the entity + host.push_undo("CVI: recompute preview"); + host.update_entity(entity); +} +``` + +Notes for plugin authors: + +- `add_entity` ignores layer locks; `set_reference_only` does not — it + refuses absent entities and locked layers exactly like the XDATA record + methods, every refusal changes nothing, and an idempotent re-mark records + no undo step. +- The designation travels with the entity: a clone → edit → replace refresh + keeps it, so a plugin marks its display data once and refreshes freely. +- Rendering and object snap are unaffected; grips, picking and the built-in + write paths are the only observers. +- Plugins that read the document through the IPC client (the + out-of-process runner) resolve entities by iterating + `document().entities()`: a received `DocumentSnapshot` does not serve + `get_entity(handle)` lookups. This is a property of the snapshot path, + not of this API — host-side lookups are unaffected. + ## Commands `run_command` takes a `CommandRequest` (`Run`, `Start`, `Point`, `Text`, `Token`, From 50db12b169ba9067a5845415fcf29fa038badd8c Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:57:13 +0200 Subject: [PATCH 5/6] refactor(scene): reference-only designation becomes a scene-scoped set (API v9) Per the upstream review of the XDATA-based storage: the host no longer writes its own state into the saved file. The marker record under the host-owned OCS APPID, the on-first-use APPID registration and the verbatim-EED rewrite handling are removed; the single source of truth is a scene-scoped in-memory set of entity handles (Scene::reference_only) that dies with the drawing tab, so a reopened file starts undesignated and the owning plugin re-marks on the document-opened notification. All behaviour sites are unchanged, driven by the set: the wire stamp moves from an XDATA read to a set lookup threaded through the tessellation pipelines (tessellate_entity and the dimension text path take the handle set as a plain parameter), and the pick filters, selection retains and update/erase refusals read Scene::is_reference_only. Undo keeps the entity-write contract: set mutations are recorded as explicit first-touch before/after values on each history delta (the parametric- constraints channel pattern). A designation inside a plugin push_undo scope joins it, a scope-less change is one step of its own, an idempotent re-mark records nothing, erasing a designated entity drops the set entry with the entity, and undo/redo restores entity and designation together. The round-trip test is inverted: reference_only_designation_is_not_written_ to_dwg_or_dxf now asserts the marker appears in neither format's bytes, that no OCS APPID or marker record survives a reload, and that the designation stays live in memory. --- crates/ocs_plugin_api/src/host.rs | 15 +- docs/plugin-host-model-completion-plan.md | 30 +++ docs/plugin-host-model.md | 27 ++- src/app/document.rs | 24 ++ src/app/history.rs | 89 ++++++- src/app/plugin_host.rs | 233 +++++++++++++------ src/app/record_api.rs | 40 ++-- src/entities/dimension.rs | 14 ++ src/scene/cache/block_cache.rs | 4 + src/scene/convert/tess.rs | 18 +- src/scene/dimension_assoc.rs | 2 +- src/scene/mod.rs | 75 +++++- src/scene/parametric_solve.rs | 4 +- src/scene/pipeline/circle_gpu.rs | 2 + src/scene/reference_only.rs | 268 ++++++++++++---------- src/scene/selection.rs | 9 + src/scene/view/render.rs | 1 + 17 files changed, 623 insertions(+), 232 deletions(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 65e9dd4e7..55aded21e 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -1189,12 +1189,21 @@ pub trait HostApi { /// plugin keeps full write access through this API: `update_entity`, /// [`update_entities_transaction`](HostApi::update_entities_transaction), /// `remove_entity` and the XDATA record methods keep working, so derived - /// display geometry can be refreshed in place. The designation survives - /// DWG/DXF save and reload. + /// display geometry can be refreshed in place. + /// + /// The designation is a scene-scoped, in-memory set keyed by entity + /// handle: it is not written to the saved DWG/DXF (the host stores no + /// state of its own in the file), it lives and dies with the drawing tab, + /// and a reloaded drawing starts undesignated — the owning plugin + /// re-marks its entities when the document opens, which makes the + /// in-memory designation fully equivalent to a persisted one for its use + /// case. /// /// Fails when the entity does not exist or is on a locked layer (the same /// policy as the XDATA record methods). A call that would not change the - /// current designation succeeds without recording an undo step. + /// current designation succeeds without recording an undo step — and + /// without publishing a document change, so idempotent re-marks cannot + /// loop with the notification dispatch. /// /// Undo composition — the entity-write contract: a change performed /// inside a plugin-opened [`HostApi::push_undo`] scope joins that scope, diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 7ded4cf43..e190c2a73 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -400,3 +400,33 @@ and `box_selections_skip_reference_only_wires`; the record surface test `entities_records_expose_the_reference_only_designation`; and the IPC round-trip `reference_only_request_and_response_roundtrip`. Like the XRecord API, a Python mapping is future work. + +**Reference-only rework: scene-scoped storage (2026-10-14, API v9).** +Supersedes the storage decision above after the upstream review: the designation is no longer +written to the file at all. The XDATA marker under the host-owned `OCS` APPID (and the APPID +registration, the verbatim-EED rewrite handling and the storage unit tests built on it) is removed; +the single source of truth is now a scene-scoped in-memory set of entity handles +(`Scene::reference_only`, `scene::reference_only`), deliberately not serialized — the host writes +nothing of its own into the saved DWG/DXF, the set dies with the drawing tab, and a reopened file +starts undesignated. Every behaviour site is unchanged and driven by the set: the wire +`tessellate_entity` stamp (threaded through the tessellation pipelines as a plain handle-set +parameter), the pick-side wire filters, the selection-gather retains, the update/erase refusals +and the MCP `entities` boolean. Undo keeps the entity-write contract through an explicit +designation channel on each history delta (the `parametric_constraints` pattern): a marking inside +a plugin-opened `push_undo` scope joins it, a scope-less change is one step of its own, an +idempotent re-mark records nothing, erasing a designated entity drops the set entry in the same +step, and undo/redo restores entity and designation together. The round-trip test is inverted: +`reference_only_designation_is_not_written_to_dwg_or_dxf` now asserts the marker appears in +neither format's bytes, that no `OCS` APPID or marker record survives a reload, and that the +designation stays live in memory. Evidence: the set-storage tests +`marking_and_unmarking_report_and_track_membership`, `removing_a_handle_forgets_it`, +`retain_present_drops_entries_of_absent_entities`, `scene_membership_is_guarded_by_entity_presence` +and `tessellated_wires_stamp_from_the_designation_set`; the host tests +`reference_only_refusals_record_no_undo_step`, +`reference_only_designation_composes_undo_like_entity_writes`, +`reference_only_designation_is_not_written_to_dwg_or_dxf`, +`erasing_a_designated_entity_undoes_designation_with_it`, +`user_paths_refuse_and_plugin_paths_write_reference_only_entities`, +`select_all_visible_skips_reference_only_entities`, +`entities_records_expose_the_reference_only_designation`; the unchanged pick tests; and the IPC +round-trip `reference_only_request_and_response_roundtrip` (unchanged). diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 2c1cd2bec..c33159a56 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -123,13 +123,23 @@ re-designation that would not change the state succeeds without recording an undo step. Undo composition is the entity-write contract: a change inside a plugin-opened `push_undo` scope joins it, so an operation composed of several writes is a single undo step under the plugin's label; with no scope open a -change is one undo step of its own. The designation survives DWG and DXF -save/reload, stored as a marker value in an XDATA record under the host-owned -`OCS` application namespace — plugins register their own APPIDs through their -manifest, so the two cannot collide. Out of scope today: entities nested -inside block instances (the block cache rebuilds instance wires without the -flag) and a Python mapping. The MCP `entities` read exposes the flag as a -`reference_only` boolean on every entity record. +change is one undo step of its own. + +**Storage is a scene-scoped in-memory set, not a file format.** The host +writes nothing of its own into the saved DWG/DXF — no APPID registration, no +XDATA marker on the entity — so a saved drawing never depends on host state +to load correctly. The set lives and dies with the drawing tab: closing a +tab forgets its designations, and a freshly opened drawing starts +undesignated. Because the designation is per handle, an in-place refresh +(clone → edit → replace) keeps it by construction. Undo keeps the contract +through an explicit designation channel on each history step (the same +pattern the parametric-constraint directory uses): undo/redo restores the +designation together with the entity it belongs to — erasing a designated +entity inside a scope, then undoing, brings back the entity *and* its +designation. Out of scope today: entities nested inside block instances +(the block cache rebuilds instance wires without the flag) and a Python +mapping. The MCP `entities` read exposes the flag as a `reference_only` +boolean on every entity record. ### Usage from a plugin @@ -169,6 +179,9 @@ Notes for plugin authors: no undo step. - The designation travels with the entity: a clone → edit → replace refresh keeps it, so a plugin marks its display data once and refreshes freely. +- The designation is scene state, not file state: it does not survive + DWG/DXF save and reload — a plugin re-marks its display data when the + document opens, and idempotent re-marks keep that loop finite. - Rendering and object snap are unaffected; grips, picking and the built-in write paths are the only observers. - Plugins that read the document through the IPC client (the diff --git a/src/app/document.rs b/src/app/document.rs index bf0b130b7..ced420528 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -766,6 +766,7 @@ impl HistorySnapshot { .saturating_add(d.named_parameters.as_ref().map_or(0, |(before, after)| { before.len().saturating_add(after.len()).saturating_mul(128) })) + .saturating_add(d.reference_designations.len().saturating_mul(16)) .saturating_add(d.label.len()), HistorySnapshot::ObjectVisibility(v) => v .before @@ -818,6 +819,12 @@ pub(super) struct DeltaSnapshot { /// `document.objects`, so it needs its own channel here rather than /// riding along with `entities`/`structure`. Almost always empty. pub(super) parametric_constraints: Vec, + /// Reference-only designations (API v9) this same command changed — set + /// state on `Scene` (see `scene::reference_only`), recorded as explicit + /// first-touch before/afters so undo/redo restores the designation with + /// the entity it belongs to (e.g. a plugin scope that both erases and + /// re-designates). Almost always empty. + pub(super) reference_designations: Vec, /// Drawing-wide parameter table changed by this transaction. pub(super) named_parameters: Option<( crate::scene::named_parameters::ParameterTable, @@ -892,6 +899,17 @@ pub(super) struct ParametricConstraintsEntryDelta { pub(super) after: crate::scene::parametric_constraints::ParametricConstraintSet, } +/// One reference-only designation's before/after value within an entity delta +/// (API v9). The designation set lives on `Scene`, not in the document, so +/// the value rides its own channel — symmetric like every other delta field, +/// undo applies the before value, redo the after value. +#[derive(Clone)] +pub(super) struct ReferenceDesignationDelta { + pub(super) handle: Handle, + pub(super) before: bool, + pub(super) after: bool, +} + #[derive(Default)] pub(super) struct HistoryState { pub(super) undo_stack: Vec, @@ -907,6 +925,12 @@ pub(super) struct PendingHistorySnapshot { pub(super) dirty_before: bool, pub(super) structure_before: CadDocument, pub(super) recorder: Arc, + /// First-touch before-values of reference-only designations (API v9) + /// mutated inside this plugin scope (see `scene::reference_only`). Set + /// mutations are not document writes, so the scope closes by reading the + /// set's current state as the after-image and keeping entries whose value + /// actually flipped. + pub(super) reference_designations_before: Vec<(Handle, bool)>, /// Kept as an undo step even when nothing changed. pub(super) keep_empty: bool, } diff --git a/src/app/history.rs b/src/app/history.rs index 01b919f14..d40509e7e 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1,8 +1,8 @@ use super::{ document::{ DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot, - ParametricConstraintsEntryDelta, PendingHistorySnapshot, StructureSnapshot, - TableEntryDelta, + ParametricConstraintsEntryDelta, PendingHistorySnapshot, ReferenceDesignationDelta, + StructureSnapshot, TableEntryDelta, }, OpenCADStudio, }; @@ -226,6 +226,7 @@ impl OpenCADStudio { active_layer: None, structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: label.into(), }; @@ -313,8 +314,26 @@ impl OpenCADStudio { let selected_after: Vec = self.tabs[i].scene.selected.iter().copied().collect(); let dirty_after = self.tabs[i].dirty; let active_layer_after = self.tabs[i].active_layer.clone(); + // The designation set is scene state mutated through the plugin API: + // a scope that (un)marked entities carries the first-touch before + // values here; the after value is whatever the set holds now, and + // only untouched-value entries drop out (an idempotent re-mark never + // got recorded in the first place). + let reference_designations: Vec<_> = pending + .reference_designations_before + .into_iter() + .filter_map(|(handle, before)| { + let after = self.tabs[i].scene.reference_only.is_marked(handle); + (before != after).then_some(ReferenceDesignationDelta { + handle, + before, + after, + }) + }) + .collect(); if !pending.keep_empty && entities.is_empty() + && reference_designations.is_empty() && !structure_changed && pending.selected_before == selected_after && pending.dirty_before == dirty_after @@ -335,6 +354,7 @@ impl OpenCADStudio { structure: structure_changed .then_some(StructureSnapshot::Full(pending.structure_before)), parametric_constraints: Vec::new(), + reference_designations, named_parameters: None, label: pending.label, }; @@ -375,10 +395,33 @@ impl OpenCADStudio { dirty_before, structure_before, recorder, + reference_designations_before: Vec::new(), keep_empty: false, }); } + /// Record the pre-mutation value of a reference-only designation (API v9) + /// into the open plugin pending scope — first touch wins. No-op when no + /// scope is open: like the XRecord methods, a designation change must open + /// its own scope before mutating, so by the time this runs a scope either + /// covers it or the caller owns one. + pub(super) fn record_reference_designation_before_pending( + &mut self, + i: usize, + handle: Handle, + before: bool, + ) { + if let Some(pending) = self.tabs[i].history.pending.as_mut() { + if !pending + .reference_designations_before + .iter() + .any(|(recorded, _)| *recorded == handle) + { + pending.reference_designations_before.push((handle, before)); + } + } + } + /// [`Self::push_undo_snapshot`] for a command that is an undo step even /// when it changes nothing (UNDO and U then name it and undo nothing). pub(super) fn push_undo_mark(&mut self, i: usize, label: impl Into) { @@ -466,6 +509,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::Layers(entries)), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: pending.label, }; @@ -523,6 +567,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::TextStyles(entries)), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: pending.label, }; @@ -580,6 +625,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::DimStyles(entries)), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: pending.label, }; @@ -645,6 +691,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::Objects(entries)), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: pending.label, }; @@ -680,6 +727,7 @@ impl OpenCADStudio { object_handles, }), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: "STYLE".to_string(), }; @@ -761,8 +809,13 @@ impl OpenCADStudio { pending.label ); } - let (entity_before, object_before, parametric_constraints_before, named_parameters_before) = - rec.into_recorded_images(); + let ( + entity_before, + object_before, + parametric_constraints_before, + named_parameters_before, + reference_designations_before, + ) = rec.into_recorded_images(); let entities: Vec<(Handle, Option>, Option>)> = entity_before .into_iter() @@ -808,6 +861,20 @@ impl OpenCADStudio { let after = self.tabs[i].scene.named_parameters().clone(); (before != after).then_some((before, after)) }); + // Designations recorded by `remove_entities`'s delta path (the + // first-touch before values) close symmetric with the set's current + // state as the after image. + let reference_designations: Vec = reference_designations_before + .into_iter() + .filter_map(|(handle, before)| { + let after = self.tabs[i].scene.reference_only.is_marked(handle); + (before != after).then_some(ReferenceDesignationDelta { + handle, + before, + after, + }) + }) + .collect(); let selected_after = self.tabs[i].scene.selected.iter().copied().collect(); let dirty_after = self.tabs[i].dirty; let mut structure = pending.structure_before.map(StructureSnapshot::Full); @@ -844,6 +911,7 @@ impl OpenCADStudio { active_layer: None, structure, parametric_constraints, + reference_designations, named_parameters, label: pending.label, }; @@ -998,8 +1066,19 @@ impl OpenCADStudio { } } } - let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo); + let mut changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo); let scene = &mut self.tabs[i].scene; + // Designations: restore the chosen side's value on the scene-owned set + // (the entity images above already applied, so a restored entity is + // present before its designation is). Each actual flip joins the + // returned per-handle change list so the deferred cache reseed and + // geometry bump re-stamp that entity's wires. + for entry in &d.reference_designations { + let value = if undo { entry.before } else { entry.after }; + if scene.set_reference_only_membership(entry.handle, value) { + changes.push((entry.handle, crate::scene::ChangeKind::Modified)); + } + } if !d.parametric_constraints.is_empty() { scene.bump_constraints_epoch(); } diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 39718d7dd..a108a4f6f 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -519,6 +519,28 @@ impl<'a> HostSession<'a> { Ok(()) } + /// Erase through the plugin path while keeping reference-only designations + /// undoable with the erase (API v9). The designation set is scene state, + /// not a document write: erasing drops the set entry in + /// `Scene::remove_entities`, so its first-touch before-image must join the + /// open plugin scope here — otherwise undoing the erase would restore the + /// entity without its designation. No-op for undesignated handles and when + /// no scope is open (then the erase follows the same no-bookkeeping + /// contract as any other entity write). + fn erase_entities_recording_designations(&mut self, handles: &[Handle]) { + for &handle in handles { + if self.app.tabs[self.tab] + .scene + .reference_only + .is_marked(handle) + { + self.app + .record_reference_designation_before_pending(self.tab, handle, true); + } + } + self.app.tabs[self.tab].scene.erase_entities_plugin(handles); + } + /// 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). @@ -548,9 +570,7 @@ impl<'a> HostSession<'a> { .document() .get_entity(handle) .map(|entity| entity.common().owner_handle); - self.app.tabs[self.tab] - .scene - .erase_entities_plugin(&[handle]); + self.erase_entities_recording_designations(&[handle]); let removed = self.document().get_entity(handle).is_none(); if removed { // The document keeps a block record's member list separately from the @@ -1140,9 +1160,7 @@ impl<'a> HostSession<'a> { return Err("the result could not be added".into()); } if delete_source { - self.app.tabs[self.tab] - .scene - .erase_entities_plugin(&[source]); + self.erase_entities_recording_designations(&[source]); self.publish_document_view(); } Ok(handle) @@ -1405,9 +1423,7 @@ impl<'a> HostSession<'a> { self.push_undo("Delete layer"); self.document_mut().layers.remove(&name); if !on_layer.is_empty() { - self.app.tabs[self.tab] - .scene - .erase_entities_plugin(&on_layer); + self.erase_entities_recording_designations(&on_layer); } self.finish_layer_change(&[name]); Ok(handle) @@ -2262,9 +2278,7 @@ impl<'a> HostSession<'a> { } } if erase_originals { - self.app.tabs[self.tab] - .scene - .erase_entities_plugin(&entities); + self.erase_entities_recording_designations(&entities); } self.finish_block_change(); Ok(handle) @@ -3134,62 +3148,64 @@ impl<'a> HostSession<'a> { /// Mark (or unmark) an entity as reference-only (API v9, additive): /// plugin-owned display geometry the built-in edit paths treat like a /// locked-layer entity, while the owning plugin keeps full write access - /// through this session. The designation survives DWG/DXF save and reload. + /// through this session. The designation is a scene-scoped in-memory set + /// keyed by handle: nothing is written to the saved DWG/DXF, the set dies + /// with the tab, and a reloaded drawing starts undesignated — the owning + /// plugin re-marks its entities when the document opens, which makes the + /// in-memory designation fully equivalent to a persisted one for its use + /// case. /// /// Every refusal — absent entity, locked layer — is decided before the /// undo scope is touched, so a failure changes nothing. A call that would /// not change the current designation succeeds without recording an undo - /// step. Undo composition: a change inside a plugin-opened `push_undo` - /// scope joins it (see [`HostSession::ensure_xrecord_undo_scope`]); a - /// scope-less call is one undo step of its own. + /// step, without bumping the geometry epoch and without publishing — the + /// idempotence that terminates the re-mark loop driven by the + /// document-changed dispatch. Undo composition: a change inside a + /// plugin-opened `push_undo` scope joins it (see + /// [`HostSession::ensure_xrecord_undo_scope`]); a scope-less call is one + /// undo step of its own. The set is scene state, not a document write, so + /// the undo delta restores it through the explicit designation channel + /// (`DeltaSnapshot::reference_designations`). pub fn set_reference_only( &mut self, handle: Handle, reference_only: bool, ) -> Result<(), String> { // Refusals precede any change (and any undo step). - let Some(entity) = self.document().get_entity(handle) else { + if self.document().get_entity(handle).is_none() { return Err(format!("entity {handle:?} does not exist")); - }; + } if self.app.tabs[self.tab].scene.is_layer_locked(handle) { return Err(format!("entity {handle:?} is on a locked layer")); } - if crate::scene::reference_only::entity_is_reference_only(entity) == reference_only { + if self.app.tabs[self.tab].scene.is_reference_only(handle) == reference_only { return Ok(()); } let own_scope = self.ensure_xrecord_undo_scope("Set Reference-Only"); - // Register the host APPID before the marker is written so the record's - // application is resolvable on save. - if reference_only { - self.ensure_app_id(crate::scene::reference_only::REFERENCE_ONLY_APP_ID); - } - let app_handle = self - .document() - .app_ids - .get(crate::scene::reference_only::REFERENCE_ONLY_APP_ID) - .map(|app| app.handle.value()); - let changed = self - .document_mut() - .get_entity_mut(handle) - .map(|entity| { - let changed = - crate::scene::reference_only::set_entity_reference_only(entity, reference_only); - // Drop stale verbatim EED for the host app so the fresh - // marker — not pre-edit bytes captured on a prior read — - // wins on the next save. - if changed { - if let Some(app_handle) = app_handle { - entity - .common_mut() - .extended_data - .raw_dwg_eed - .retain(|(a, _)| *a != app_handle); - } - } - changed - }) - .unwrap_or(false); - debug_assert!(changed, "designation state was checked before the mutation"); + // First-touch before-image for the pending scope's designation + // channel, recorded before the set changes (see + // `Scene::record_undo_reference_designation_before`). + let before = self.app.tabs[self.tab] + .scene + .reference_only + .is_marked(handle); + self.app + .record_reference_designation_before_pending(self.tab, handle, before); + self.app.tabs[self.tab] + .scene + .set_reference_only_membership(handle, reference_only); + debug_assert!( + self.app.tabs[self.tab] + .scene + .reference_only + .is_marked(handle) + == reference_only, + "designation state was checked before the mutation" + ); + // The wires carry the click-through stamp baked at tessellation, so a + // designation change must rebuild them: bump_geometry clears the + // tessellation memos and resident wire sets exactly like an entity + // edit would. self.bump_geometry(); self.set_dirty(); self.publish_document_view(); @@ -13491,9 +13507,7 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) .get_mut("0") .map(|layer| layer.unlock()); assert!( - !crate::scene::reference_only::entity_is_reference_only( - host.document().get_entity(handle).unwrap() - ), + !host.app.tabs[0].scene.is_reference_only(handle), "refusals must not partially apply the designation" ); assert_eq!( @@ -13526,7 +13540,7 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) // Undo reverts the unmark, redo re-applies it. drop(host); app.undo_steps(1); - let mut host = HostSession::new(&mut app, 0); + let host = HostSession::new(&mut app, 0); assert!(host.app.tabs[0].scene.is_reference_only(handle)); drop(host); app.redo_steps(1); @@ -13549,7 +13563,7 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) // Undoing the plugin operation reverts it whole. app.undo_steps(1); - let mut host = HostSession::new(&mut app, 0); + let host = HostSession::new(&mut app, 0); assert!( host.app.tabs[0] .scene @@ -13565,32 +13579,101 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) } #[test] - fn reference_only_survives_dxf_and_dwg_round_trips() { + fn reference_only_designation_is_not_written_to_dwg_or_dxf() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + host.set_reference_only(handle, true).unwrap(); + // The designation lives in the scene's in-memory set only. + assert!(host.app.tabs[0].scene.is_reference_only(handle)); + + // The host writes nothing of its own into the document: no APPID + // registration, no marker XDATA on the designated entity. + assert!(host.document().app_ids.get("OCS").is_none()); + assert!(host + .document() + .get_entity(handle) + .expect("designated entity") + .common() + .extended_data + .get_record("OCS") + .is_none()); + + let contains_marker = + |bytes: &[u8]| bytes.windows(15).any(|window| window == b"REFERENCE_ONLY"); + + // DWG: the marker string appears nowhere in the saved bytes, and a + // reopened file carries neither the host APPID nor the marker record. + let dwg = codec::DwgWriter::write_to_vec(host.document()).unwrap(); + assert!( + !contains_marker(&dwg), + "DWG save must not contain the designation marker" + ); + let reloaded = crate::io::load_bytes("reference-only.dwg", dwg).unwrap(); + assert!(reloaded.app_ids.get("OCS").is_none()); + assert!(reloaded + .get_entity(handle) + .expect("the line survives the round trip") + .common() + .extended_data + .get_record("OCS") + .is_none()); + assert!( + !contains_marker(&codec::DwgWriter::write_to_vec(&reloaded).unwrap()), + "a re-save of the reopened file stays marker-free" + ); + + // DXF: same contract for the text format. + let dxf = codec::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); + assert!( + !contains_marker(&dxf), + "DXF save must not contain the designation marker" + ); + let reloaded = crate::io::load_bytes("reference-only.dxf", dxf).unwrap(); + assert!(reloaded.app_ids.get("OCS").is_none()); + assert!(reloaded + .get_entity(handle) + .expect("the line survives the round trip") + .common() + .extended_data + .get_record("OCS") + .is_none()); + } + + #[test] + fn erasing_a_designated_entity_undoes_designation_with_it() { let mut app = OpenCADStudio::new_for_test(); app.tabs[0].is_start = false; let mut host = HostSession::new(&mut app, 0); let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); host.set_reference_only(handle, true).unwrap(); - let flagged = |document: &codec::CadDocument| { - document - .get_entity(handle) - .is_some_and(crate::scene::reference_only::entity_is_reference_only) - }; - // The host APPID the marker lives under travels with the record. - assert!(host.document().app_ids.get("OCS").is_some()); + // The plugin erase path removes a designated entity inside a scope: + // entity and set entry leave together. + host.push_undo("CVI: replace the underlying model"); + host.remove_entity(handle); + drop(host); + app.finish_pending_history(0); + let host = HostSession::new(&mut app, 0); + assert!(host.app.tabs[0].scene.document.get_entity(handle).is_none()); + assert!(!host.app.tabs[0].scene.is_reference_only(handle)); + drop(host); - let dwg = codec::DwgWriter::write_to_vec(host.document()).unwrap(); - let reloaded = crate::io::load_bytes("reference-only.dwg", dwg).unwrap(); - assert!(flagged(&reloaded), "DWG save/reopen keeps the designation"); - assert!(reloaded.app_ids.get("OCS").is_some()); - - let dxf = codec::DxfWriter::new(host.document()) - .write_to_vec() - .unwrap(); - let reloaded = crate::io::load_bytes("reference-only.dxf", dxf).unwrap(); - assert!(flagged(&reloaded), "DXF save/reopen keeps the designation"); - assert!(reloaded.app_ids.get("OCS").is_some()); + // Undoing the erase restores the entity AND its designation: the set + // mutation rode the scope as an explicit designation channel. + app.undo_steps(1); + let host = HostSession::new(&mut app, 0); + assert!( + host.app.tabs[0].scene.document.get_entity(handle).is_some(), + "undo restored the erased entity" + ); + assert!( + host.app.tabs[0].scene.is_reference_only(handle), + "undo restored the designation together with its entity" + ); } #[test] diff --git a/src/app/record_api.rs b/src/app/record_api.rs index 53e63a807..af7d10cfb 100644 --- a/src/app/record_api.rs +++ b/src/app/record_api.rs @@ -226,7 +226,7 @@ fn record( value } -fn entity_record(entity: &codec::EntityType) -> Result { +fn entity_record(entity: &codec::EntityType, reference_only: bool) -> Result { let (kind, properties) = enum_parts(entity)?; let mut value = record( "entities", @@ -245,7 +245,8 @@ fn entity_record(entity: &codec::EntityType) -> Result { "record_type".into(), json!(crate::entities::names::dxf_name(entity)), ); - let reference_only = crate::scene::reference_only::entity_is_reference_only(entity); + // Scene-scoped designation (API v9): the set membership of this entity's + // handle, resolved by the caller from the live scene. object.insert("reference_only".into(), json!(reference_only)); Ok(value) } @@ -307,12 +308,13 @@ fn map_records( .collect() } -fn collection_records( - document: &codec::CadDocument, - collection: &str, -) -> Result, String> { +fn collection_records(scene: &crate::scene::Scene, collection: &str) -> Result, String> { + let document = &scene.document; match collection { - "entities" => document.entities().map(entity_record).collect(), + "entities" => document + .entities() + .map(|entity| entity_record(entity, scene.is_reference_only(entity.common().handle))) + .collect(), "objects" => { let mut objects: Vec<_> = document.objects.iter().collect(); objects.sort_by_key(|(handle, _)| handle.value()); @@ -1387,7 +1389,7 @@ impl OpenCADStudio { } let definitions = referenced_definitions(&root_type); let runtime_schema = collection.and_then(|collection| { - collection_records(&self.tabs[self.active_tab].scene.document, collection) + collection_records(&self.tabs[self.active_tab].scene, collection) .ok()? .into_iter() .find(|record| { @@ -1431,20 +1433,20 @@ impl OpenCADStudio { } pub(super) fn record_query(&self, request: &Value) -> Value { - let document = &self.tabs[self.active_tab].scene.document; + let scene = &self.tabs[self.active_tab].scene; let Some(collection) = request["collection"].as_str() else { return self.record_capabilities(); }; let mut records = Vec::new(); if collection == "all" { for (name, _) in COLLECTIONS { - match collection_records(document, name) { + match collection_records(scene, name) { Ok(mut values) => records.append(&mut values), Err(error) => return failure("serialization_failed", error), } } } else { - match collection_records(document, collection) { + match collection_records(scene, collection) { Ok(values) => records = values, Err(error) => return failure("unknown_collection", error), } @@ -1712,14 +1714,18 @@ mod tests { Vector3::new(10.0, 0.0, 0.0), ), )); - fn mark(app: &mut OpenCADStudio, i: usize, handle: Handle, on: bool) { - if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { - crate::scene::reference_only::set_entity_reference_only(entity, on); - } + // The designation is plugin-mutated scene state: mark through the + // same host API a plugin uses, so the read surface reflects the + // live set rather than any stored marker. + fn mark(app: &mut OpenCADStudio, handle: Handle, on: bool) { + let i = app.active_tab; + crate::app::plugin_host::HostSession::new(app, i) + .set_reference_only(handle, on) + .expect("mark through the plugin host API"); } // The read surface reports the designation alongside the record. - mark(&mut app, i, handle, true); + mark(&mut app, handle, true); let records = app.record_query(&json!({"collection":"entities"})); assert_eq!(records["count"], 1); assert_eq!( @@ -1744,7 +1750,7 @@ mod tests { assert_eq!(refused["code"], json!("reference_only")); // Clearing the designation is reported too. - mark(&mut app, i, handle, false); + mark(&mut app, handle, false); let records = app.record_query(&json!({"collection":"entities"})); assert_eq!(records["records"][0]["reference_only"], json!(false)); } diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 07f2292b6..fd021e0f2 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3598,6 +3598,7 @@ fn apply_dimension_jog( } pub trait DimensionTess { + #[allow(clippy::too_many_arguments)] fn tessellate( &self, document: &CadDocument, @@ -3611,10 +3612,12 @@ pub trait DimensionTess { bg_color: [f32; 4], view_aabb: Option<[f32; 4]>, world_per_pixel: Option, + reference_only: &rustc_hash::FxHashSet, ) -> Vec; } impl DimensionTess for Dimension { + #[allow(clippy::too_many_arguments)] fn tessellate( &self, document: &CadDocument, @@ -3628,6 +3631,7 @@ impl DimensionTess for Dimension { bg_color: [f32; 4], view_aabb: Option<[f32; 4]>, world_per_pixel: Option, + reference_only: &rustc_hash::FxHashSet, ) -> Vec { tessellate_dimension_inner( document, @@ -3642,10 +3646,12 @@ impl DimensionTess for Dimension { bg_color, view_aabb, world_per_pixel, + reference_only, ) } } +#[allow(clippy::too_many_arguments)] fn tessellate_dimension_inner( document: &CadDocument, handle: Handle, @@ -3662,6 +3668,7 @@ fn tessellate_dimension_inner( bg_color: [f32; 4], view_aabb: Option<[f32; 4]>, world_per_pixel: Option, + reference_only: &rustc_hash::FxHashSet, ) -> Vec { if let Dimension::Arc(arc) = dim { if let Some((local_arc, normal)) = arc_dimension_in_ocs(arc) { @@ -3678,6 +3685,7 @@ fn tessellate_dimension_inner( bg_color, None, world_per_pixel, + reference_only, ); for wire in &mut wires { map_wire_ocs_to_wcs(wire, normal); @@ -3700,6 +3708,7 @@ fn tessellate_dimension_inner( bg_color, None, world_per_pixel, + reference_only, ); for wire in &mut wires { map_wire_ocs_to_wcs(wire, normal); @@ -3721,6 +3730,7 @@ fn tessellate_dimension_inner( bg_color, None, world_per_pixel, + reference_only, ); for wire in &mut wires { map_wire_ocs_to_wcs(wire, normal); @@ -4420,6 +4430,7 @@ fn tessellate_dimension_inner( view_aabb, world_per_pixel, text_color, + reference_only, ); for mut w in text_wires { w.name = name.clone(); @@ -4437,6 +4448,7 @@ fn tessellate_dimension_inner( view_aabb, world_per_pixel, text_color, + reference_only, ); for mut w in tol_wires { w.name = name.clone(); @@ -8890,6 +8902,7 @@ mod linear_transform_tests { [0.0, 0.0, 0.0, 1.0], None, None, + &rustc_hash::FxHashSet::default(), ); let mut points = Vec::new(); for wire in &wires { @@ -9221,6 +9234,7 @@ mod layout_parity_tests { [0.0, 0.0, 0.0, 1.0], None, None, + &rustc_hash::FxHashSet::default(), ); let mut points = Vec::new(); let mut fills = 0; diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index a7d2e648f..6e409e0a2 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -772,6 +772,9 @@ fn tessellate_sub_local( // top-level entities, applied per-defn. let wires_out = if let EntityType::Dimension(dimension) = sub { use crate::entities::dimension::DimensionTess; + // Block-definition members never carry the reference-only + // designation (it targets top-level geometry — see the API v9 + // known-limit note), so the block cache tessellates with an empty set. dimension.tessellate( doc, h, @@ -784,6 +787,7 @@ fn tessellate_sub_local( bg_color, None, None, + &HashSet::default(), ) } else { tessellate::tessellate( diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index a2e5e384a..87f2630b3 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -473,6 +473,7 @@ fn section_symbol_wires( /// so it picks up the standard text LOD ladder (baseline / greek / full), /// then re-color the returned wires with the dimension's resolved text colour /// (so DIMCLRT / DIMSTYLE colours win over the synthetic Text's defaults). +#[allow(clippy::too_many_arguments)] pub(crate) fn tessellate_entity_dim_text( document: &codec::CadDocument, selected: &HashSet, @@ -483,6 +484,7 @@ pub(crate) fn tessellate_entity_dim_text( view_aabb: Option<[f32; 4]>, world_per_pixel: Option, text_color: [f32; 4], + reference_only: &HashSet, ) -> Vec { let mut wires = tessellate_entity( document, @@ -496,6 +498,7 @@ pub(crate) fn tessellate_entity_dim_text( view_aabb, world_per_pixel, false, + reference_only, ); for w in &mut wires { // Synth dim text carries no real entity colour — paint everything @@ -508,6 +511,7 @@ pub(crate) fn tessellate_entity_dim_text( } wires } +#[allow(clippy::too_many_arguments)] pub(crate) fn tessellate_entity( document: &codec::CadDocument, selected: &HashSet, @@ -520,6 +524,7 @@ pub(crate) fn tessellate_entity( view_aabb: Option<[f32; 4]>, world_per_pixel: Option, paper_space: bool, + reference_only: &HashSet, ) -> Vec { let mut wires = tessellate_entity_inner( document, @@ -533,6 +538,7 @@ pub(crate) fn tessellate_entity( view_aabb, world_per_pixel, paper_space, + reference_only, ); let layer_plottable = document @@ -550,8 +556,10 @@ pub(crate) fn tessellate_entity( // Reference-only entities (API v9) render and snap normally but must not // be picked by the built-in edit paths; the pick-side wire filters in // `scene::pick::hit_test` and the selection-gather retains consume the - // flag. - if crate::scene::reference_only::entity_is_reference_only(e) { + // flag. The stamp is driven by the scene's in-memory designation set — + // outside `Scene` this stays pure data, so the parallel tessellation + // workers only need the handle set, not scene access. + if reference_only.contains(&e.common().handle) { for wire in &mut wires { wire.reference_only = true; } @@ -559,6 +567,7 @@ pub(crate) fn tessellate_entity( wires } +#[allow(clippy::too_many_arguments)] fn tessellate_entity_inner( document: &codec::CadDocument, selected: &HashSet, @@ -577,6 +586,10 @@ fn tessellate_entity_inner( // Retained through recursive INSERT expansion; PSLTSCALE itself is applied // by the viewport's GPU uniform so it never changes resident wire content. _paper_space: bool, + // The scene's reference-only designation set (API v9): drives the + // `WireModel::reference_only` stamp for dimensions' synthesis paths that + // bypass the wrapper's post-stamp. + reference_only: &HashSet, ) -> Vec { let contextual = crate::scene::annotative::entity_for_annotation_context( document, @@ -1029,6 +1042,7 @@ fn tessellate_entity_inner( bg_color, view_aabb, world_per_pixel, + reference_only, ); for w in &mut wires { w.aci = aci; diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index c51da0c15..bbb40ae90 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -2279,7 +2279,7 @@ mod tests { scene.sync_diameter_association_angle(dimension); let recording = scene.take_undo_recording().expect("undo recording"); - let (_, objects, _, _) = recording.into_recorded_images(); + let (_, objects, _, _, _) = recording.into_recorded_images(); assert!(objects .iter() .any(|(handle, before)| *handle == association && before.is_some())); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 779a46109..d4ccd2271 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -82,6 +82,10 @@ pub(crate) use convert::tess::{ /// Keep construction-history curves in the same per-entity tessellation memo /// as their parent, so navigation never rebuilds them on every frame. +/// +/// `reference_only` is the scene's designation set: its membership decides +/// whether the produced wires carry the `WireModel::reference_only` click- +/// through stamp (see `scene::reference_only`). #[allow(clippy::too_many_arguments)] pub(crate) fn tessellate_entity( document: &CadDocument, @@ -95,6 +99,7 @@ pub(crate) fn tessellate_entity( view_aabb: Option<[f32; 4]>, world_per_pixel: Option, paper_space: bool, + reference_only: &HashSet, ) -> Vec { let mut wires = convert::tess::tessellate_entity( document, @@ -108,6 +113,7 @@ pub(crate) fn tessellate_entity( view_aabb, world_per_pixel, paper_space, + reference_only, ); if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) { let handle = entity.common().handle; @@ -159,6 +165,7 @@ pub(crate) fn tessellate_entity( view_aabb, world_per_pixel, paper_space, + reference_only, ) }; for wire in &mut construction { @@ -758,6 +765,13 @@ pub struct UndoRecording { >, parametric_constraints_order: Vec, named_parameters_before: Option, + /// First-touch before-images of reference-only designations (API v9) + /// changed by the command — almost always empty. The designation set + /// lives on `Scene`, not in the document, so like + /// `parametric_constraints_before` it needs its own channel. Erasing a + /// designated entity drops its set entry here so undo restores entity + /// and designation together. + reference_designations_before: Vec<(Handle, bool)>, poisoned: bool, } @@ -773,6 +787,7 @@ impl UndoRecording { && self.object_order.is_empty() && self.parametric_constraints_order.is_empty() && self.named_parameters_before.is_none() + && self.reference_designations_before.is_empty() } /// Consume the recording directories in deterministic first-touch @@ -790,6 +805,7 @@ impl UndoRecording { parametric_constraints::ParametricConstraintSet, )>, Option, + Vec<(Handle, bool)>, ) { let entities = self .order @@ -810,11 +826,13 @@ impl UndoRecording { .map(|before| (scope, before)) }) .collect(); + let reference_designations = std::mem::take(&mut self.reference_designations_before); ( entities, objects, parametric_constraints, self.named_parameters_before, + reference_designations, ) } @@ -2062,6 +2080,11 @@ pub struct Scene { pub selected_constraint: Option, /// Session-only ISOLATEOBJECTS / HIDEOBJECTS state. Never written to DWG/DXF. pub object_isolation: ObjectIsolationState, + /// Scene-scoped reference-only designations (API v9): plugin-owned + /// display geometry the built-in edit paths refuse. In-memory only, + /// keyed by handle, lives and dies with this scene — never written to + /// DWG/DXF (see `scene::reference_only`). + pub(crate) reference_only: reference_only::ReferenceOnlyDesignation, /// Entity handles temporarily removed from the base render while an /// interactive grip preview draws their live replacement. /// Separate from object isolation so a grip can never activate the @@ -2694,6 +2717,7 @@ impl Scene { selected_order: Vec::new(), selected_constraint: None, object_isolation: ObjectIsolationState::default(), + reference_only: Default::default(), preview_hidden: HashSet::default(), command_preview_hidden: HashSet::default(), refedit_keep: None, @@ -3341,6 +3365,26 @@ impl Scene { } } + /// Record a reference-only designation's value before its first change + /// within the open delta recording (API v9). The designation set lives on + /// `Scene`, outside the document, so it needs its own channel — like + /// the parametric-constraint directory above — for undo to restore the + /// designation together with the entity it belongs to. First touch wins. + pub(crate) fn record_undo_reference_designation_before(&mut self, handle: Handle) { + if let Some(recording) = self.undo_recording.as_mut() { + if !recording + .reference_designations_before + .iter() + .any(|(recorded, _)| *recorded == handle) + { + let before = self.reference_only.is_marked(handle); + recording + .reference_designations_before + .push((handle, before)); + } + } + } + /// Flag the open recording as touching non-entity state (a new layer, a /// group edit, a `*D` block record) that a pure-entity delta cannot /// restore. The app's per-command predicate keeps this from firing. @@ -3398,6 +3442,13 @@ impl Scene { } } + // History replay can apply an entity removal the designation channels + // predate (an "added" side rolled back). Sweep so the set never keeps + // an entry for an absent entity; exact designations come right after + // this from the delta's own channel. + if !self.reference_only.is_empty() { + self.reference_only.retain_present(&self.document); + } if !changes.is_empty() { self.invalidate_dependency_index(); } @@ -7250,6 +7301,7 @@ impl Scene { None, None, anno_scale_override.is_some(), + self.reference_only.handles(), ); // Solid B-rep corners snap via the mesh edge list (their wire // path is an empty shell). Enrich before the memo/assembly split @@ -9153,9 +9205,15 @@ impl Scene { /// locked-layer entity while its owning plugin keeps write access through /// the plugin host API. Absent entities are never reference-only. pub fn is_reference_only(&self, handle: Handle) -> bool { - self.document - .get_entity(handle) - .is_some_and(reference_only::entity_is_reference_only) + self.reference_only.is_marked(handle) && self.document.get_entity(handle).is_some() + } + + /// Insert or remove `handle` in the scene-scoped designation set, + /// returning whether membership changed. Pure storage: no epoch bump, no + /// undo bookkeeping — those belong to the callers (the plugin host API + /// mutation and the history channels). + pub(crate) fn set_reference_only_membership(&mut self, handle: Handle, on: bool) -> bool { + self.reference_only.set(handle, on) } /// The name of the locked layer `handle` sits on, if any (for messages). @@ -11347,6 +11405,9 @@ impl Scene { // `world_offset` subtraction even though `current_layout != "Model"`. // Decide based on the block being tessellated, not the layout. let is_model_block = block_handle == self.model_space_block_handle(); + // The reference-only designation set drives the wire stamp; captured + // as a shared ref like `doc`/`sel` so the rayon workers can read it. + let reference_only_set = self.reference_only.handles(); let bg = if self.current_layout == "Model" { self.bg_color } else { @@ -11470,6 +11531,7 @@ impl Scene { view_aabb, wpp, paper, + reference_only_set, ); (e.common().handle, Arc::new(w)) }) @@ -11509,6 +11571,7 @@ impl Scene { view_aabb, wpp, paper, + reference_only_set, ) }) .collect(); @@ -11543,6 +11606,7 @@ impl Scene { view_aabb, wpp, paper, + reference_only_set, )), Ok(None) => Some(Vec::new()), Err(()) => None, @@ -12563,6 +12627,7 @@ vis_index={:.1} visible_probe={:.1}", None, None, paper, + self.reference_only.handles(), ); // Same solid-corner snaps as the resident paths, so one-off consumers // (overlay changed-wires, edit previews) agree with hit-test/snap. @@ -14048,7 +14113,7 @@ mod delta_undo_tests { let handle = scene.add_entity(EntityType::RasterImage(image)); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects, _parametric_constraints, _named_parameters) = + let (entities, objects, _parametric_constraints, _named_parameters, _designations) = rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, handle); @@ -14073,7 +14138,7 @@ mod delta_undo_tests { scene.erase_entities(&[h1]); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects, _parametric_constraints, _named_parameters) = + let (entities, objects, _parametric_constraints, _named_parameters, _designations) = rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, h1); diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 58290abeb..ca72e56f7 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -4502,7 +4502,7 @@ mod tests { .take_undo_recording() .expect("an undo recording should still be open"); - let (entities, _objects, _parametric_constraints, _named_parameters) = + let (entities, _objects, _parametric_constraints, _named_parameters, _designations) = recording.into_recorded_images(); let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect(); assert!( @@ -4559,7 +4559,7 @@ mod tests { let recording = scene .take_undo_recording() .expect("an undo recording should still be open"); - let (_entities, _objects, parametric_constraints, _named_parameters) = + let (_entities, _objects, parametric_constraints, _named_parameters, _designations) = recording.into_recorded_images(); assert_eq!( parametric_constraints.len(), diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index 8e3d8e06b..b2c8285e2 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -588,6 +588,7 @@ mod tests { let h = doc.add_entity(donut).unwrap(); let entity = doc.get_entity(h).unwrap(); let selected = rustc_hash::FxHashSet::default(); + let reference_only = rustc_hash::FxHashSet::default(); let wires = crate::scene::convert::tess::tessellate_entity( &doc, &selected, @@ -600,6 +601,7 @@ mod tests { None, None, false, + &reference_only, ); assert_eq!( wires.len(), diff --git a/src/scene/reference_only.rs b/src/scene/reference_only.rs index 7ecfe93c9..24a82d098 100644 --- a/src/scene/reference_only.rs +++ b/src/scene/reference_only.rs @@ -7,143 +7,181 @@ //! plugin keeps full write access through the plugin host API, so it can //! refresh derived geometry in place. //! -//! The designation persists as a marker value inside an XDATA record under a -//! host-owned APPID, so it round-trips DWG and DXF with the entity and never -//! collides with plugin APPID namespaces (plugins register their own -//! application names through their manifest). +//! Storage is a scene-scoped in-memory set of entity handles — the host +//! writes nothing of its own into the saved DWG/DXF: no APPID registration, +//! no XDATA marker on the entity, no host-owned state a saved file depends +//! on. The set lives and dies with the scene (per drawing tab): closing a +//! tab forgets its designations, and a freshly opened drawing starts +//! undesignated. The owning plugin re-marks its entities when the host +//! dispatches the document-opened notification (API v9's +//! `BuiltinPlugin::on_notification` surface), which makes the in-memory +//! designation fully equivalent to a persisted one for its use case. +//! +//! Undo keeps the entity-write contract: a designation change performed +//! inside a plugin-opened `push_undo` scope joins that scope, a scope-less +//! change is one undo step of its own, and undo/redo restores the +//! designation with the entities it belongs to. Because the set is not part +//! of the document, set mutations are recorded as explicit first-touch +//! before-images on the history delta (see +//! `DeltaSnapshot::reference_designations`), the same channel pattern +//! `parametric_constraints` uses. -use codec::entities::EntityType; -use codec::xdata::{ExtendedDataRecord, XDataValue}; +use codec::types::Handle; +use rustc_hash::FxHashSet; -/// Host-owned APPID carrying the designation. The name belongs to the host -/// alone; plugin records live under the plugin's own registered APPIDs. -pub const REFERENCE_ONLY_APP_ID: &str = "OCS"; - -/// Marker value (DXF group code 1000) that flags an entity reference-only. -/// Human-readable so an exported file explains itself. -const REFERENCE_ONLY_MARKER: &str = "REFERENCE_ONLY"; - -/// True when `value` is the reference-only marker. -fn is_marker(value: &XDataValue) -> bool { - matches!(value, XDataValue::String(text) if text.as_str() == REFERENCE_ONLY_MARKER) +/// The in-memory designation set owned by a [`Scene`](crate::Scene). +/// +/// Deliberately tiny: a plain set of handles plus the operations the scene, +/// the plugin host and the history channels use. Not `Serialize` — the set +/// is never written anywhere and never leaves the process. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct ReferenceOnlyDesignation { + handles: FxHashSet, } -/// True when `entity` carries the reference-only designation. Tolerant read: -/// a record without the marker (foreign or damaged content) is not -/// reference-only. -pub fn entity_is_reference_only(entity: &EntityType) -> bool { - let Some(record) = entity - .common() - .extended_data - .get_record(REFERENCE_ONLY_APP_ID) - else { - return false; - }; - record.values.iter().any(is_marker) -} - -/// Set (or clear) the designation on `entity`, returning whether the entity -/// changed. Only the marker value is added or removed; foreign values under -/// the host APPID are preserved verbatim, and a record left without values is -/// dropped so a cleared flag leaves no empty record behind. -pub fn set_entity_reference_only(entity: &mut EntityType, reference_only: bool) -> bool { - if entity_is_reference_only(entity) == reference_only { - return false; +impl ReferenceOnlyDesignation { + /// Raw membership (by handle) — the caller guarantees the entity exists, + /// or accepts a stale entry; [`Scene::is_reference_only`] does the + /// existential check. + pub(crate) fn is_marked(&self, handle: Handle) -> bool { + self.handles.contains(&handle) } - let xd = &mut entity.common_mut().extended_data; - if reference_only { - let mut record = xd - .get_record(REFERENCE_ONLY_APP_ID) - .cloned() - .unwrap_or_else(|| ExtendedDataRecord::new(REFERENCE_ONLY_APP_ID)); - record - .values - .push(XDataValue::String(REFERENCE_ONLY_MARKER.to_owned())); - xd.upsert_record(record); - } else { - let Some(mut record) = xd.get_record(REFERENCE_ONLY_APP_ID).cloned() else { - return false; - }; - record.values.retain(|value| !is_marker(value)); - if record.values.is_empty() { - xd.remove_record(REFERENCE_ONLY_APP_ID); + + /// Insert or remove `handle`, returning whether the membership changed. + /// Idempotent: re-marking a designated handle reports `false` and touches + /// nothing, which is what terminates the re-mark loop driven by the + /// document-changed notification dispatch. + pub(crate) fn set(&mut self, handle: Handle, on: bool) -> bool { + if on { + self.handles.insert(handle) } else { - xd.upsert_record(record); + self.handles.remove(&handle) } } - true + + /// Forget `handle` — called when its entity is erased so the set never + /// leaks an entry for a removed entity. The undo channels restore the + /// entry together with the entity on undo. + pub(crate) fn remove(&mut self, handle: Handle) { + self.handles.remove(&handle); + } + + /// The marked handles, for the tessellation pipelines (wire stamping) + /// and the history channels. + pub(crate) fn handles(&self) -> &FxHashSet { + &self.handles + } + + /// True when nothing is designated. + pub(crate) fn is_empty(&self) -> bool { + self.handles.is_empty() + } + + /// Drop entries whose entities no longer exist. The undo/redo channels + /// model the exact designation state, so this is only a defensive sweep + /// after history replay (an entity the chosen delta side removed must + /// never stay designated through a stale entry). + pub(crate) fn retain_present(&mut self, document: &codec::CadDocument) { + self.handles + .retain(|handle| document.get_entity(*handle).is_some()); + } } #[cfg(test)] mod tests { use super::*; - use codec::entities::Line; + use codec::entities::{EntityType, Line}; - fn entity() -> EntityType { - EntityType::Line(Line::new()) + fn handle(value: u64) -> Handle { + Handle::new(value) } #[test] - fn marking_flags_and_unmarking_clears() { - let mut entity = entity(); - assert!(!entity_is_reference_only(&entity)); - assert!(set_entity_reference_only(&mut entity, true)); - assert!(entity_is_reference_only(&entity)); - // Idempotent: a second mark changes nothing and reports it. - assert!(!set_entity_reference_only(&mut entity, true)); - assert!(set_entity_reference_only(&mut entity, false)); - assert!(!entity_is_reference_only(&entity)); - assert!(!set_entity_reference_only(&mut entity, false)); - // A cleared flag leaves no empty record behind. - assert!(entity - .common() - .extended_data - .get_record(REFERENCE_ONLY_APP_ID) - .is_none()); + fn marking_and_unmarking_report_and_track_membership() { + let mut designation = ReferenceOnlyDesignation::default(); + assert!(!designation.is_marked(handle(7))); + assert!(designation.set(handle(7), true)); + assert!(designation.is_marked(handle(7))); + // Idempotent: a second mark changes nothing and reports it — the + // property that terminates re-mark loops. + assert!(!designation.set(handle(7), true)); + assert!(designation.set(handle(7), false)); + assert!(!designation.is_marked(handle(7))); + assert!(!designation.set(handle(7), false)); + assert!(designation.handles().is_empty()); } #[test] - fn foreign_values_under_the_host_app_id_survive() { - let mut entity = entity(); - let mut record = ExtendedDataRecord::new(REFERENCE_ONLY_APP_ID); - record.values.push(XDataValue::Integer16(7)); - entity.common_mut().extended_data.add_record(record); - // A record without the marker is not reference-only. - assert!(!entity_is_reference_only(&entity)); - assert!(set_entity_reference_only(&mut entity, true)); - let record = entity - .common() - .extended_data - .get_record(REFERENCE_ONLY_APP_ID) - .expect("record exists"); - assert_eq!( - record.values, - vec![ - XDataValue::Integer16(7), - XDataValue::String(REFERENCE_ONLY_MARKER.to_owned()), - ] - ); - assert!(set_entity_reference_only(&mut entity, false)); - let record = entity - .common() - .extended_data - .get_record(REFERENCE_ONLY_APP_ID) - .expect("record survives"); - assert_eq!(record.values, vec![XDataValue::Integer16(7)]); + fn removing_a_handle_forgets_it() { + let mut designation = ReferenceOnlyDesignation::default(); + designation.set(handle(7), true); + designation.remove(handle(7)); + assert!(!designation.is_marked(handle(7))); + // Removing an unmarked handle is a no-op. + designation.remove(handle(8)); + assert!(designation.is_empty()); } #[test] - fn other_apps_are_untouched() { - let mut entity = entity(); - assert!(set_entity_reference_only(&mut entity, true)); - let mut foreign = ExtendedDataRecord::new("CVIFC"); - foreign.values.push(XDataValue::String("road".to_owned())); - entity.common_mut().extended_data.add_record(foreign); - assert!(set_entity_reference_only(&mut entity, false)); - assert!(entity - .common() - .extended_data - .get_record("CVIFC") - .is_some_and(|record| record.values.len() == 1)); + fn retain_present_drops_entries_of_absent_entities() { + let mut document = codec::CadDocument::new(); + let live = document.add_entity(EntityType::Line(Line::new())).unwrap(); + let mut designation = ReferenceOnlyDesignation::default(); + designation.set(live, true); + designation.set(handle(999_999), true); + designation.retain_present(&document); + assert!(designation.is_marked(live)); + assert!(!designation.is_marked(handle(999_999))); + } + + #[test] + fn scene_membership_is_guarded_by_entity_presence() { + let mut scene = crate::scene::Scene::new(); + let handle = scene + .document + .add_entity(EntityType::Line(Line::new())) + .unwrap(); + assert!(!scene.is_reference_only(handle)); + assert!(scene.set_reference_only_membership(handle, true)); + assert!(scene.is_reference_only(handle)); + // A stale set entry alone never designates: absent entities are not + // reference-only even if the set still lists them. + scene.set_reference_only_membership(Handle::new(999_999), true); + assert!(!scene.is_reference_only(Handle::new(999_999))); + } + + #[test] + fn tessellated_wires_stamp_from_the_designation_set() { + let document = codec::CadDocument::new(); + let line = EntityType::Line(Line::new()); + let handle = line.common().handle; + let selected: rustc_hash::FxHashSet = Default::default(); + let none: rustc_hash::FxHashSet = Default::default(); + let mut designated: rustc_hash::FxHashSet = Default::default(); + designated.insert(handle); + let context = crate::scene::cache::block_cache::BlockCache::default(); + for designated in [&none, &designated] { + let wires = crate::scene::tessellate_entity( + &document, + &selected, + None, + [0.0, 0.0, 0.0, 1.0], + 1.0, + None, + &line, + Some(&context), + None, + None, + false, + designated, + ); + assert!(!wires.is_empty()); + assert!( + wires + .iter() + .all(|wire| wire.reference_only == designated.contains(&handle)), + "every wire carries the set's stamp for its entity" + ); + } } } diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 95dc55353..d533f57d2 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1143,6 +1143,12 @@ impl Scene { let before = self.document.objects.get(&manager_handle).cloned(); self.record_undo_object_before(manager_handle, before); } + // The designation set is scene state, not document state: a + // designated entity's set entry joins the same first-touch + // recording so undo restores entity and designation together. + if self.reference_only.is_marked(h) { + self.record_undo_reference_designation_before(h); + } } self.delete_solid_history(h); if let Some(settings_handle) = settings_handle { @@ -1161,6 +1167,9 @@ impl Scene { } self.remember_removed_cache_categories(h); self.document.remove_entity_arc(h); + // The set entry dies with its entity (an absent entity is never + // reference-only). Undo restores it through the recorded channel. + self.reference_only.remove(h); selection_changed |= self.selected.remove(&h); self.selected_order.retain(|selected| *selected != h); if self.hover_highlight == Some(h) { diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 9cb39f5c5..a87a65475 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -4163,6 +4163,7 @@ impl Scene { None, None, content_viewport, + self.reference_only.handles(), ); for wire in &mut context_wires { wire.color = tint; From 2cdc1df0219d4e3782a24cf7716f4ae25135c49a Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:15:42 +0200 Subject: [PATCH 6/6] feat(app): dispatch HostNotifications to native plugins (API v9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the no-load-callback gap: on_notification was a dead dispatch sink — a plugin had no way to learn that the active document changed, the selection changed, or that a document opened, so runtime designations could not be re-seeded after a load. Dispatch the existing plain variants at the fingerprint-guarded boundaries the v4 bridge already uses: DocumentChanged (the active tab's settled geometry epoch) and SelectionChanged (the selection set) leave through BuiltinPlugin::on_notification at the post-message boundary — after the mutation settles and pending history scopes closed, never mid-undo or mid-pick — and at the automation post-op boundary. Plugin-initiated writes publish immediately after settling, like the drawing-changed event; the boundary fingerprint guard deduplicates. Add the additive HostNotification::DocumentOpened { tab_id } variant (discriminant 10, the counterpart of DocumentTabClosed): fired exactly once per whole-document install — file open, template new, system open — keyed on a new per-tab document generation, so an edit can never repeat it and a reload fires it once. The API version stays v9 (the bump is unreleased). Write semantics for handlers, tested and documented: a handler may call HostApi write methods through the same session (set_reference_only at minimum); its write bumps the epoch, the next boundary notifies once more, and because re-marking already-designated geometry is a documented no-op — no epoch bump, no undo step, no publish — the loop terminates. History (undo/redo) replay itself emits nothing; the boundary is the only channel. No Python mapping, like the rest of this increment. --- crates/ocs_plugin_api/src/host.rs | 23 +- docs/plugin-host-model-completion-plan.md | 36 ++- docs/plugin-host-model.md | 30 ++- src/app/automation.rs | 295 ++++++++++++++++++++++ src/app/control/sheets.rs | 3 + src/app/document.rs | 27 ++ src/app/mod.rs | 8 + src/app/plugin_host.rs | 7 +- src/app/update/file.rs | 4 + src/app/update/mod.rs | 38 +++ src/plugin/v4_support.rs | 76 ++++++ 11 files changed, 536 insertions(+), 11 deletions(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 55aded21e..e45ef77aa 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -396,6 +396,15 @@ pub enum HostNotification { tab_id: u64, epoch: u64, }, + /// V9 a document became resident in a tab: a whole document replaced the + /// tab's previous content — opened from file, created from a template, or + /// loaded by an automation/system open. The counterpart of + /// [`HostNotification::DocumentTabClosed`]: with both, a plugin can keep + /// per-document state (e.g. runtime designations) in sync from load to + /// close. Discriminant 10. + DocumentOpened { + tab_id: u64, + }, /// Fallback for notification variants added in future minor revisions. /// Carries the raw bincode payload so an older peer can ignore it without /// failing deserialization. @@ -447,6 +456,10 @@ impl Serialize for HostNotification { bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; bincode::serialize_into(&mut bytes, epoch).map_err(serde::ser::Error::custom)?; } + HostNotification::DocumentOpened { tab_id } => { + bytes.push(10); + bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; + } HostNotification::Unknown(raw) => bytes.extend_from_slice(raw), } bytes.serialize(serializer) @@ -490,6 +503,9 @@ impl<'de> Deserialize<'de> for HostNotification { 9 => bincode::deserialize(rest) .map(|(tab_id, epoch)| HostNotification::DrawingChanged { tab_id, epoch }) .map_err(serde::de::Error::custom), + 10 => bincode::deserialize(rest) + .map(|tab_id| HostNotification::DocumentOpened { tab_id }) + .map_err(serde::de::Error::custom), _ => Ok(HostNotification::Unknown(bytes)), } } @@ -1195,9 +1211,10 @@ pub trait HostApi { /// handle: it is not written to the saved DWG/DXF (the host stores no /// state of its own in the file), it lives and dies with the drawing tab, /// and a reloaded drawing starts undesignated — the owning plugin - /// re-marks its entities when the document opens, which makes the - /// in-memory designation fully equivalent to a persisted one for its use - /// case. + /// re-marks its entities when + /// [`on_notification`](BuiltinPlugin::on_notification) delivers + /// [`HostNotification::DocumentOpened`], making the in-memory designation + /// fully equivalent to a persisted one for its use case. /// /// Fails when the entity does not exist or is on a locked layer (the same /// policy as the XDATA record methods). A call that would not change the diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index e190c2a73..c2995e4cc 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -401,7 +401,7 @@ and `box_selections_skip_reference_only_wires`; the record surface test `reference_only_request_and_response_roundtrip`. Like the XRecord API, a Python mapping is future work. -**Reference-only rework: scene-scoped storage (2026-10-14, API v9).** +**Reference-only rework: scene-scoped storage and notification dispatch (2026-10-14, API v9).** Supersedes the storage decision above after the upstream review: the designation is no longer written to the file at all. The XDATA marker under the host-owned `OCS` APPID (and the APPID registration, the verbatim-EED rewrite handling and the storage unit tests built on it) is removed; @@ -418,8 +418,27 @@ idempotent re-mark records nothing, erasing a designated entity drops the set en step, and undo/redo restores entity and designation together. The round-trip test is inverted: `reference_only_designation_is_not_written_to_dwg_or_dxf` now asserts the marker appears in neither format's bytes, that no `OCS` APPID or marker record survives a reload, and that the -designation stays live in memory. Evidence: the set-storage tests -`marking_and_unmarking_report_and_track_membership`, `removing_a_handle_forgets_it`, +designation stays live in memory. + +The same rework closes the missing load callback with real notification dispatch: the host now +delivers the existing `HostNotification`s to every loaded native plugin through +`BuiltinPlugin::on_notification` — `DocumentChanged` (the active tab's settled geometry epoch) +and `SelectionChanged` (the plain selection set) at the fingerprint-guarded message boundaries +the v4 bridge already uses (`notify_plugins_selection_changed` / `notify_plugins_document_changed`, +plus the automation post-op boundary), and a new additive `DocumentOpened { tab_id }` variant +(discriminant 10, the counterpart of the existing `DocumentTabClosed`) that fires once per whole +document install — file open, template new, system open — keyed on a new per-tab document +generation. Boundary rule: dispatch happens after the mutation settles and pending history scopes +closed, never mid-undo/mid-pick, and history (undo/redo) replay itself emits nothing — the +boundary is the only channel; plugin-initiated writes publish immediately after settling, like +the drawing-changed event, with the boundary fingerprint deduplicating. Handlers may call HostApi +write methods through the same session (`set_reference_only` at minimum), and the loop closes +because an idempotent re-mark bumps nothing: no epoch, no undo step, no publish. With +`DocumentOpened`, a plugin re-marks on every load and the in-memory designation is fully +equivalent to a persisted one for its use case; the remaining gap (a plugin process restarted +while its drawing is open) stays open and documented — the plugin's own session persistence +covers it on reopen. No Python mapping, like the rest of this increment. Evidence: the set-storage +tests `marking_and_unmarking_report_and_track_membership`, `removing_a_handle_forgets_it`, `retain_present_drops_entries_of_absent_entities`, `scene_membership_is_guarded_by_entity_presence` and `tessellated_wires_stamp_from_the_designation_set`; the host tests `reference_only_refusals_record_no_undo_step`, @@ -428,5 +447,12 @@ and `tessellated_wires_stamp_from_the_designation_set`; the host tests `erasing_a_designated_entity_undoes_designation_with_it`, `user_paths_refuse_and_plugin_paths_write_reference_only_entities`, `select_all_visible_skips_reference_only_entities`, -`entities_records_expose_the_reference_only_designation`; the unchanged pick tests; and the IPC -round-trip `reference_only_request_and_response_roundtrip` (unchanged). +`entities_records_expose_the_reference_only_designation`; the unchanged pick tests; the dispatch +tests `an_edit_dispatches_document_changed_once_per_epoch`, +`a_selection_change_dispatches_selection_changed`, +`a_document_open_dispatches_document_opened_once`, +`a_file_open_dispatches_document_opened_through_the_message_flow`, +`idempotent_designation_remarks_cannot_loop_the_dispatch`, +`history_replay_emits_no_plugin_notifications_itself` and +`dispatching_without_loaded_plugins_leaves_the_app_state_untouched`; and the IPC round-trip +`reference_only_request_and_response_roundtrip` (unchanged). diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index c33159a56..487033337 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -141,6 +141,31 @@ designation. Out of scope today: entities nested inside block instances mapping. The MCP `entities` read exposes the flag as a `reference_only` boolean on every entity record. +**Re-marking on load: `HostNotification` dispatch (API v9).** The host +dispatches the existing `HostNotification`s to every loaded native plugin +through `BuiltinPlugin::on_notification`: `DocumentChanged` (carrying the +active tab's settled geometry epoch), `SelectionChanged` (the plain selection +set), and a new `DocumentOpened` — the counterpart of the existing +`DocumentTabClosed` — which fires once whenever a whole document becomes +resident in a tab (file open, template new, system open). With +`DocumentOpened`, the owning plugin re-marks its entities when a drawing +loads, which makes the in-memory designation fully equivalent to a persisted +one for its use case. Dispatch happens at the message boundary — after the +mutation settles and pending history scopes have closed — never mid-undo or +mid-pick; history (undo/redo) replay itself emits nothing, the boundary is +the only channel. Plugin-initiated writes publish immediately after they +settle (the same contract the drawing-changed event follows), and the +boundary's fingerprint guard deduplicates. Handlers may call HostApi write +methods through the same session (`set_reference_only` at minimum; +`update_entity` for reference-only refresh goes through the plugin write +paths): a handler's write bumps the epoch, the next boundary notifies once +more, and because a re-mark of already-designated geometry is a documented +no-op — no epoch bump, no undo step, no publish — the loop terminates. The +remaining gap stays open and documented: a plugin process restarted while +its drawing is open has no snapshot of the designations (the plugin's own +session persistence closes it; reopen from disk re-marks via +`DocumentOpened`). Not mapped to Python. + ### Usage from a plugin Build the derived geometry and designate it inside one `push_undo` scope, so @@ -180,8 +205,9 @@ Notes for plugin authors: - The designation travels with the entity: a clone → edit → replace refresh keeps it, so a plugin marks its display data once and refreshes freely. - The designation is scene state, not file state: it does not survive - DWG/DXF save and reload — a plugin re-marks its display data when the - document opens, and idempotent re-marks keep that loop finite. + DWG/DXF save and reload. Subscribe to `on_notification` and re-mark on + `DocumentOpened`; the idempotent re-mark guarantees the + `DocumentChanged`-driven loop terminates. - Rendering and object snap are unaffected; grips, picking and the built-in write paths are the only observers. - Plugins that read the document through the IPC client (the diff --git a/src/app/automation.rs b/src/app/automation.rs index 45d8bac5c..1a1f365e5 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -690,6 +690,10 @@ impl OpenCADStudio { self.notify_plugins_selection_changed(); #[cfg(not(target_arch = "wasm32"))] self.notify_plugins_document_changed(); + // The `new` / `open` / template ops replace a tab's whole document; + // announce it on the same post-op boundary (API v9). + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_document_opened(); res } @@ -719,6 +723,9 @@ impl OpenCADStudio { // The headless session starts on the welcome (Start) tab, which // blocks drawing commands; turn it into a real drawing. self.tabs[i].is_start = false; + // A fresh document became resident (API v9): mint a new + // generation so the post-op boundary announces `DocumentOpened`. + self.tabs[i].note_document_loaded(); self.entity_summary() } #[cfg(not(target_arch = "wasm32"))] @@ -751,6 +758,10 @@ impl OpenCADStudio { self.tabs[i].current_path = Some(path_buf); self.tabs[i].is_start = false; self.tabs[i].scene.rebuild_derived_caches(); + // The opened document settled (API v9): mint a new + // generation so the post-op boundary announces + // `DocumentOpened` once for this load. + self.tabs[i].note_document_loaded(); let mut summary = self.entity_summary(); if dropped > 0 { if let Some(obj) = summary.as_object_mut() { @@ -1718,6 +1729,290 @@ mod tests { app.notify_plugins_document_changed(); assert_eq!(app.last_plugin_document, Some(published)); } + + // ── Native-plugin notification dispatch (API v9) ───────────────────── + // + // The app-level tests observe the dispatch seam in + // `plugin::v4_support`: one recorded entry stands for one broadcast to + // every loaded native plugin, so counts here are counts per plugin. The + // write-semantics rule under test: dispatch happens at the message + // boundary — after the mutation settled, never mid-undo/mid-pick — so + // handlers may write through the plugin host API, and the fingerprint + // guards plus idempotent re-marks keep that loop finite. + + fn plugin_dispatch_log() -> Vec { + crate::plugin::v4_support::take_test_dispatch_log() + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn an_edit_dispatches_document_changed_once_per_epoch() { + use codec::entities::Point; + use codec::EntityType; + + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let _ = plugin_dispatch_log(); + let tab = app.active_tab; + + let mut host = super::super::plugin_host::HostSession::new(&mut app, tab); + host.add_entity(EntityType::Point(Point::new())); + drop(host); + app.notify_plugins_document_changed(); + + let epoch = app.tabs[tab].scene.geometry_epoch; + let log = plugin_dispatch_log(); + let changed: Vec<_> = log + .iter() + .filter(|entry| *entry == &format!("DocumentChanged({epoch})")) + .collect(); + assert_eq!( + changed.len(), + 1, + "one settled edit dispatches exactly one DocumentChanged per plugin: {log:?}" + ); + + // The same epoch at the next boundary dispatches nothing further. + app.notify_plugins_document_changed(); + let log = plugin_dispatch_log(); + assert!( + log.iter() + .all(|entry| *entry != format!("DocumentChanged({epoch})")), + "the fingerprint guard suppresses duplicate dispatches: {log:?}" + ); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_selection_change_dispatches_selection_changed() { + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#); + let _ = plugin_dispatch_log(); + let i = app.active_tab; + + let handle = app.tabs[i] + .scene + .document + .entities() + .next() + .expect("the drawn line") + .common() + .handle; + let selection: rustc_hash::FxHashSet = std::iter::once(handle).collect(); + app.tabs[i].scene.replace_selection(selection); + app.notify_plugins_selection_changed(); + + let log = plugin_dispatch_log(); + assert!( + log.contains(&"SelectionChanged(1 handles)".to_string()), + "a real selection change dispatches SelectionChanged: {log:?}" + ); + + // Unchanged selection at the next boundary: nothing further. + app.notify_plugins_selection_changed(); + let log = plugin_dispatch_log(); + assert_eq!(log.len(), 0, "no duplicate dispatch: {log:?}"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_document_open_dispatches_document_opened_once() { + let mut app = super::OpenCADStudio::new_for_test(); + let _ = plugin_dispatch_log(); + + app.automation_op(r#"{"op":"new"}"#); + let tab_id = app.tabs[app.active_tab].id; + let log = plugin_dispatch_log(); + let opened: Vec<_> = log + .iter() + .filter(|entry| **entry == format!("DocumentOpened({tab_id})")) + .collect(); + assert_eq!( + opened.len(), + 1, + "a new document announces exactly one open: {log:?}" + ); + + // A follow-up op does not repeat it: generation-keyed dispatch. + app.automation_op(r#"{"op":"query","type":"Line"}"#); + let log = plugin_dispatch_log(); + assert!( + log.iter() + .all(|entry| *entry != format!("DocumentOpened({tab_id})")), + "no duplicate open dispatch: {log:?}" + ); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_file_open_dispatches_document_opened_through_the_message_flow() { + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let path = + std::env::temp_dir().join(format!("ocs_opened_dispatch_{}.dxf", std::process::id(),)); + crate::io::save(&app.tabs[app.active_tab].scene.document, &path) + .expect("save the open fixture"); + + app.opening = Some(crate::app::OpenProgress { + id: 7, + name: "ocs_opened_dispatch.dxf".to_string(), + source_path: Some(path.clone()), + size_bytes: 0, + state: std::sync::Arc::new(crate::io::OpenProgressState::new( + crate::app::OPEN_PHASE_CACHING, + )), + started: std::time::Instant::now(), + recovery_error: None, + recovery_read_stats: None, + fingerprint: None, + }); + let bytes = std::fs::read(&path).expect("read the fixture back"); + let (document, _dropped) = + crate::io::load_bytes_finalized(&path, bytes).expect("reparse the fixture"); + let caches = crate::scene::build_derived_caches_with_progress(&document, &|_| {}, None); + let _ = plugin_dispatch_log(); + let _ = app.update(crate::app::Message::FileOpened( + 7, + Ok(( + "ocs_opened_dispatch.dxf".to_string(), + path.clone(), + document, + caches, + )), + )); + + let tab_id = app.tabs[app.active_tab].id; + let log = plugin_dispatch_log(); + let opened: Vec<_> = log + .iter() + .filter(|entry| **entry == format!("DocumentOpened({tab_id})")) + .collect(); + assert_eq!( + opened.len(), + 1, + "the file-open message announces exactly one open: {log:?}" + ); + // The next message boundary does not repeat it. + app.notify_plugins_document_opened(); + let log = plugin_dispatch_log(); + assert_eq!(log.len(), 0, "generation-keyed dispatch dedupes: {log:?}"); + let _ = std::fs::remove_file(&path); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn idempotent_designation_remarks_cannot_loop_the_dispatch() { + use codec::entities::Point; + use codec::EntityType; + + // Models the consumer plugin's re-mark handler: on DocumentChanged it + // re-marks its entities through the plugin host API. The write goes + // through, but because a re-mark of already-designated geometry is a + // documented no-op — no epoch bump, no undo step, no publish — the + // notification loop terminates instead of feeding itself. + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let _ = plugin_dispatch_log(); + let tab = app.active_tab; + + let mut host = super::super::plugin_host::HostSession::new(&mut app, tab); + let handle = host.add_entity(EntityType::Point(Point::new())); + host.set_reference_only(handle, true).expect("mark"); + // The handler's "write": an idempotent re-mark after the notification. + host.set_reference_only(handle, true) + .expect("idempotent re-mark"); + drop(host); + assert!(app.tabs[tab].scene.is_reference_only(handle)); + + app.notify_plugins_document_changed(); + let epoch = app.tabs[tab].scene.geometry_epoch; + let log = plugin_dispatch_log(); + let changed: Vec<_> = log + .iter() + .filter(|entry| **entry == format!("DocumentChanged({epoch})")) + .collect(); + assert_eq!( + changed.len(), + 1, + "the designation write dispatched once, not one per re-mark: {log:?}" + ); + + // A further boundary run finds the same epoch: the loop has ended. + app.notify_plugins_document_changed(); + let log = plugin_dispatch_log(); + assert_eq!(log.len(), 0, "idempotent re-marks emit nothing: {log:?}"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn history_replay_emits_no_plugin_notifications_itself() { + use codec::entities::Point; + use codec::EntityType; + + // Undo/redo replay never publishes: the notification boundary is the + // only channel, so replaying history steps in isolation is silent. + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let _ = plugin_dispatch_log(); + let tab = app.active_tab; + + let mut host = super::super::plugin_host::HostSession::new(&mut app, tab); + let handle = host.add_entity(EntityType::Point(Point::new())); + host.set_reference_only(handle, true).expect("mark"); + drop(host); + + let _ = plugin_dispatch_log(); + app.undo_steps(1); + assert!( + !app.tabs[tab].scene.is_reference_only(handle), + "undo reverted the designation" + ); + let log = plugin_dispatch_log(); + assert_eq!( + log.len(), + 0, + "history replay itself emits no plugin notifications: {log:?}" + ); + + app.redo_steps(1); + assert!(app.tabs[tab].scene.is_reference_only(handle)); + let log = plugin_dispatch_log(); + assert_eq!(log.len(), 0, "redo replay is equally silent: {log:?}"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn dispatching_without_loaded_plugins_leaves_the_app_state_untouched() { + // The default `on_notification` is a no-op; with no plugin processes + // loaded, a full boundary pass must not disturb the host. + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let epoch = app.tabs[i].scene.geometry_epoch; + let selected: Vec = app.tabs[i].scene.selected.iter().copied().collect(); + + let _ = plugin_dispatch_log(); + app.notify_plugins_selection_changed(); + app.notify_plugins_document_changed(); + app.notify_plugins_document_opened(); + let log = plugin_dispatch_log(); + + assert_eq!(app.tabs[i].scene.geometry_epoch, epoch); + assert_eq!( + app.tabs[i] + .scene + .selected + .iter() + .copied() + .collect::>(), + selected + ); + assert!( + log.iter().all(|entry| !entry.starts_with("DocumentOpened")), + "a start-tab-free app does not announce spurious opens: {log:?}" + ); + } use crate::app::OpenCADStudio; use serde_json::{json, Value}; diff --git a/src/app/control/sheets.rs b/src/app/control/sheets.rs index 42cdb2f2c..80352695f 100644 --- a/src/app/control/sheets.rs +++ b/src/app/control/sheets.rs @@ -463,6 +463,9 @@ impl OpenCADStudio { self.tabs[i].current_path = None; self.tabs[i].is_start = false; self.tabs[i].dirty = true; + // A whole document just became resident in the tab (API v9): mint a + // fresh generation so the boundary announces `DocumentOpened` once. + self.tabs[i].note_document_loaded(); Ok(purged) } diff --git a/src/app/document.rs b/src/app/document.rs index ced420528..4d7a35ebb 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -18,6 +18,17 @@ use std::sync::Arc; static NEXT_DOCUMENT_TAB_ID: AtomicU64 = AtomicU64::new(1); +/// Monotonic source for `DocumentTab::document_generation` (API v9): every +/// minted value is process-unique, so any freshly installed document — file +/// open, template new, system open — differs from every generation a plugin +/// has been told about, and the opened-notification guard fires exactly once +/// per load. +static NEXT_DOCUMENT_GENERATION: AtomicU64 = AtomicU64::new(1); + +fn next_document_generation() -> u64 { + NEXT_DOCUMENT_GENERATION.fetch_add(1, Ordering::Relaxed) +} + // ── Dynamic input ────────────────────────────────────────────────────────── /// One quantity shown in the dynamic-input overlay near the cursor. @@ -147,6 +158,13 @@ pub(super) struct DocumentTab { /// Monotonic committed-edit/undo/redo revision. Background save completion /// uses it with scene epochs so an older snapshot never clears newer work. pub(super) edit_revision: u64, + /// Monotonic identity of the document currently resident in this tab: a + /// fresh value is minted whenever a whole document replaces the tab's + /// content (file open, template new, system open), and edits leave it + /// alone. The plugin-opened notification guard + /// (`OpenCADStudio::notify_plugins_document_opened`, API v9) keys on it + /// so `DocumentOpened` dispatches exactly once per load. + pub(super) document_generation: u64, pub(super) tab_title: String, pub(super) properties: PropertiesPanel, pub(super) layers: LayerPanel, @@ -623,6 +641,7 @@ impl DocumentTab { dirty: false, recovery_save_as_required: false, edit_revision: 0, + document_generation: next_document_generation(), prev_selection: Vec::new(), tab_title: format!("Drawing{}", n), properties: PropertiesPanel::empty(), @@ -692,6 +711,14 @@ impl DocumentTab { t } + /// A whole document just replaced this tab's content (file open, template + /// new, system open): mint a fresh generation so the message-boundary + /// dispatch announces `DocumentOpened` exactly once for this load (API + /// v9). Call after the document and its derived caches have settled. + pub(super) fn note_document_loaded(&mut self) { + self.document_generation = next_document_generation(); + } + pub(super) fn tab_display_name(&self) -> String { match &self.current_path { Some(p) => p diff --git a/src/app/mod.rs b/src/app/mod.rs index f4972877f..0c9819f5c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1118,6 +1118,12 @@ pub(super) struct OpenCADStudio { /// boundaries as well as after plugin-initiated writes. #[cfg(not(target_arch = "wasm32"))] last_plugin_document: Option<(u64, u64)>, + /// `(tab id, document generation)` last dispatched as `DocumentOpened` — + /// a tab's generation changes only when a whole document replaced its + /// content, so the opened notification fires once per load, never per + /// edit (API v9). + #[cfg(not(target_arch = "wasm32"))] + last_plugin_document_opened: Option<(u64, u64)>, /// External add-on packages found in the plugins folder, refreshed when the /// Plugin Manager opens. external_plugins: Vec, @@ -4735,6 +4741,8 @@ impl OpenCADStudio { last_plugin_selection: None, #[cfg(not(target_arch = "wasm32"))] last_plugin_document: None, + #[cfg(not(target_arch = "wasm32"))] + last_plugin_document_opened: None, external_plugins: Vec::new(), loaded_plugin_ids: rustc_hash::FxHashSet::default(), plugin_load_errors: rustc_hash::FxHashMap::default(), diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index a108a4f6f..0195ab66b 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -142,6 +142,11 @@ impl<'a> HostSession<'a> { 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); + // The plain `DocumentChanged` follows the same immediate + // publish contract (API v9): a plugin-initiated write settles + // here, so native plugins' `on_notification` sees it now — + // the message boundary's fingerprint guard then dedupes. + v4_support::publish_document_changed(key.1); } v4_support::publish_document_view_v4(self.tab_id(), doc); self.app.last_plugin_document = @@ -3151,7 +3156,7 @@ impl<'a> HostSession<'a> { /// through this session. The designation is a scene-scoped in-memory set /// keyed by handle: nothing is written to the saved DWG/DXF, the set dies /// with the tab, and a reloaded drawing starts undesignated — the owning - /// plugin re-marks its entities when the document opens, which makes the + /// plugin re-marks on the `DocumentOpened` notification, which makes the /// in-memory designation fully equivalent to a persisted one for its use /// case. /// diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 9d78560ef..844ec7ccc 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2244,6 +2244,10 @@ impl OpenCADStudio { .set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1); } self.opening.take(); + // The document and its derived caches have settled: mark the tab's + // generation so the next message boundary announces `DocumentOpened` + // to native plugins exactly once for this load (API v9). + self.tabs[i].note_document_loaded(); // The tab list just changed (and the active tab with it): remember // it for the next boot's restore flow (web session memory). #[cfg(target_arch = "wasm32")] diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 33bc51dac..06fdabf02 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -308,6 +308,11 @@ impl OpenCADStudio { self.last_plugin_selection = Some(key); let handles = self.tabs[i].scene.selected_handles_in_order(); crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles.clone()); + // Same guarded boundary, second surface (API v9): the plain + // `SelectionChanged` reaches every loaded native plugin through + // `BuiltinPlugin::on_notification`, without a shared-view + // subscription. + crate::plugin::v4_support::publish_selection_changed(handles.clone()); self.control_record_selection_event(&handles); } @@ -327,6 +332,35 @@ impl OpenCADStudio { 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); + // Same guarded boundary, second surface (API v9): the plain + // `DocumentChanged` carries the settled geometry epoch to every + // loaded native plugin through `BuiltinPlugin::on_notification`. + crate::plugin::v4_support::publish_document_changed(tab.scene.geometry_epoch); + } + + /// Announce that a whole document became resident in the active tab — + /// opened from file, created from a template, or loaded by a system open + /// (API v9). Keyed on `(tab id, document generation)`, which changes only + /// when a tab's content is wholesale replaced, so an edit can never fire + /// it twice and a reload fires it exactly once. Part of the same + /// post-message boundary as the selection and document dispatches: runs + /// after the mutation settles and pending history scopes closed, never + /// inside history replay or interactive picking. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn notify_plugins_document_opened(&mut self) { + if self.active_tab >= self.tabs.len() { + return; + } + let i = self.active_tab; + if self.tabs[i].is_start { + return; + } + let key = (self.tabs[i].id, self.tabs[i].document_generation); + if self.last_plugin_document_opened == Some(key) { + return; + } + self.last_plugin_document_opened = Some(key); + crate::plugin::v4_support::publish_document_opened(self.tabs[i].id); } pub fn update(&mut self, msg: Message) -> Task { @@ -540,6 +574,10 @@ impl OpenCADStudio { self.notify_plugins_selection_changed(); #[cfg(not(target_arch = "wasm32"))] self.notify_plugins_document_changed(); + // A message may also have installed a whole document into a tab (file + // open, template new); announce it on the same boundary (API v9). + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_document_opened(); // OTRACK acquires tracking points only while a command or grip drag is // running; drop them once neither is active so the temporary tracking // points / vectors disappear when the command ends (issue #64). A grip diff --git a/src/plugin/v4_support.rs b/src/plugin/v4_support.rs index a1993e49c..e66eec9a9 100644 --- a/src/plugin/v4_support.rs +++ b/src/plugin/v4_support.rs @@ -48,6 +48,31 @@ pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec) { broadcast(HostNotification::SelectionChangedV4 { tab_id, handles }); } +/// Broadcast the tab-independent `HostNotification::SelectionChanged` to every +/// loaded native plugin (API v9): the plain-handle variant of the selection +/// event, dispatched at the same fingerprint-guarded message boundary as the +/// tab-scoped one above (`OpenCADStudio::notify_plugins_selection_changed`). +pub fn publish_selection_changed(handles: Vec) { + broadcast(HostNotification::SelectionChanged { handles }); +} + +/// Broadcast the tab-independent `HostNotification::DocumentChanged` to every +/// loaded native plugin (API v9): the geometry epoch of the active tab's +/// document after a mutation settled, dispatched at the fingerprint-guarded +/// message boundary (`OpenCADStudio::notify_plugins_document_changed`). +pub fn publish_document_changed(version: u64) { + broadcast(HostNotification::DocumentChanged { version }); +} + +/// Broadcast `HostNotification::DocumentOpened` to every loaded native plugin +/// (API v9): a whole document became resident in a tab — opened from file, +/// created from a template, or loaded by a system open. The counterpart of +/// [`HostNotification::DocumentTabClosed`], so a plugin can re-seed runtime +/// per-document state (e.g. reference-only designations) from load to close. +pub fn publish_document_opened(tab_id: u64) { + broadcast(HostNotification::DocumentOpened { tab_id }); +} + /// Broadcast a change in a tab's active interactive command. pub fn publish_command_state_changed(tab_id: u64, command: Option) { broadcast(HostNotification::CommandStateChanged { tab_id, command }); @@ -71,7 +96,58 @@ pub fn notification_handler() -> NotificationHandler { } fn broadcast(notification: HostNotification) { + #[cfg(test)] + record_test_dispatch(¬ification); crate::plugin::external::with_manager(|mgr| { mgr.broadcast_notification(None, notification); }); } + +// ── Test-only dispatch observation ────────────────────────────────────────── +// +// App-level tests assert boundary dispatch behaviour through this per-thread +// log: `cargo test` runs each test on its own thread, so a thread-local log +// records exactly the notifications this test's app instance broadcast, +// without cross-test interference. One recorded entry stands for one fan-out +// to every loaded native plugin (the manager's broadcast), so counts here are +// counts per plugin. + +#[cfg(test)] +thread_local! { + static TEST_DISPATCH_LOG: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +#[cfg(test)] +fn record_test_dispatch(notification: &HostNotification) { + let kind = match notification { + HostNotification::DocumentChanged { version } => { + format!("DocumentChanged({version})") + } + HostNotification::SelectionChanged { handles } => { + format!("SelectionChanged({} handles)", handles.len()) + } + HostNotification::DocumentOpened { tab_id } => format!("DocumentOpened({tab_id})"), + HostNotification::DocumentTabClosed { tab_id } => format!("DocumentTabClosed({tab_id})"), + HostNotification::DrawingChanged { tab_id, epoch } => { + format!("DrawingChanged({tab_id}, {epoch})") + } + HostNotification::SelectionChangedV4 { tab_id, .. } => { + format!("SelectionChangedV4({tab_id})") + } + HostNotification::DocumentChangedV4 { tab_id, version } => { + format!("DocumentChangedV4({tab_id}, {version})") + } + HostNotification::CommandStateChanged { tab_id, .. } => { + format!("CommandStateChanged({tab_id})") + } + _ => "Other".to_string(), + }; + TEST_DISPATCH_LOG.with(|log| log.borrow_mut().push(kind)); +} + +/// Drain this thread's recorded plugin-notification broadcasts (test-only). +#[cfg(test)] +pub fn take_test_dispatch_log() -> Vec { + TEST_DISPATCH_LOG.with(|log| std::mem::take(&mut *log.borrow_mut())) +}