Merge pull request #1747 from schoeller/reference_only_entities

Reference-only entities (plugin API v9): a scene-scoped, in-memory
designation for plugin-owned display geometry, plus HostNotification
dispatch to native plugins so they can re-mark on DocumentOpened.

Resolves the new DeltaSnapshot site in block_editor.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-14 23:46:42 +03:00
commit 68330ce39e
40 files changed
+1977 -85

No files matched your search

+94
View File
@@ -362,3 +362,97 @@ 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.
**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;
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.
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`,
`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; 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).
+112 -1
View File
@@ -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,117 @@ 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.
**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.
**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
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.
- The designation is scene state, not file state: it does not survive
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
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`,