feat(app): dispatch HostNotifications to native plugins (API v9)

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.
This commit is contained in:
Sebastian committed 2026-10-14 21:15:42 +02:00
1 parent 50db12b169
commit 2cdc1df021
11 files changed
+536 -11

No files matched your search

+20 -3
View File
@@ -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
+31 -5
View File
@@ -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).
+28 -2
View File
@@ -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
+295
View File
@@ -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<String> {
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<codec::Handle> = 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<codec::Handle> = 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::<Vec<_>>(),
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};
+3
View File
@@ -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)
}
+27
View File
@@ -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
+8
View File
@@ -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<crate::plugin::external::ExternalPlugin>,
@@ -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(),
+6 -1
View File
@@ -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.
///
+4
View File
@@ -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")]
+38
View File
@@ -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<Message> {
@@ -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
+76
View File
@@ -48,6 +48,31 @@ pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec<codec::Handle>) {
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<codec::Handle>) {
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<String>) {
broadcast(HostNotification::CommandStateChanged { tab_id, command });
@@ -71,7 +96,58 @@ pub fn notification_handler() -> NotificationHandler {
}
fn broadcast(notification: HostNotification) {
#[cfg(test)]
record_test_dispatch(&notification);
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<Vec<String>> =
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<String> {
TEST_DISPATCH_LOG.with(|log| std::mem::take(&mut *log.borrow_mut()))
}