forked from 1b-agent/opencadstudio-upstream
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:
commit
68330ce39e
40 files changed
+1977
-85
No files matched your search
@@ -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
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user