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())) +}