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

+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