diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 40901c113..6fba1b930 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -1263,7 +1263,7 @@ fn generate_version_info(out_dir: &Path) { "acadrust_version": opencadcodec.version.to_string(), "acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()), "rustc_version": rustc_version, - "api_version": 8, + "api_version": 9, "api_version_min_supported": 3, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 7e68dc0bf..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)), } } @@ -1175,6 +1191,45 @@ pub trait HostApi { fn list_xrecords(&self, _dictionary_path: &[&str]) -> Result, String> { Err("XRecord listing is not supported by this host".to_owned()) } + + // ── Reference-only designation (added in API v9; appended at the end to + // keep vtable indices stable for older plugins) ────────────────────────── + + /// Mark (or unmark) an entity as reference-only (API v9, additive). + /// + /// A reference-only entity is plugin-owned display geometry: it renders + /// normally, but the built-in edit paths treat it like an entity on a + /// locked layer — it is neither grip-editable nor click-pickable (a click + /// passes through to the editable entity underneath), window and box + /// selections skip it, and user-driven writes refuse it. The owning + /// plugin keeps full write access through this API: `update_entity`, + /// [`update_entities_transaction`](HostApi::update_entities_transaction), + /// `remove_entity` and the XDATA record methods keep working, so derived + /// display geometry can be refreshed in place. + /// + /// The designation is a scene-scoped, in-memory set keyed by entity + /// 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 + /// [`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 + /// current designation succeeds without recording an undo step — and + /// without publishing a document change, so idempotent re-marks cannot + /// loop with the notification dispatch. + /// + /// Undo composition — the entity-write contract: a change performed + /// inside a plugin-opened [`HostApi::push_undo`] scope joins that scope, + /// so an operation composed of several writes is a single undo step under + /// the plugin's own label. With no scope open the change is one undo step + /// of its own. Refusals record no undo step. + fn set_reference_only(&mut self, _handle: Handle, _reference_only: bool) -> Result<(), String> { + Err("The reference-only designation is not supported by this host".to_owned()) + } } /// Configuration properties for creating or modifying a layer. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 2d7c0e4fb..2f9d09c41 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -692,6 +692,23 @@ impl HostApi for PluginHostApi { Err(error) => Err(error.to_string()), } } + + fn set_reference_only(&mut self, handle: Handle, reference_only: bool) -> Result<(), String> { + match self.client.request(PluginRequest::SetReferenceOnly { + handle, + reference_only, + }) { + Ok(PluginResponse::SetReferenceOnlyResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected reference-only response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } } /// Sentinel reader used when the shared-memory view could not be initialized. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 51f206f0e..44a2f2ec0 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -226,6 +226,11 @@ pub enum PluginRequest { ListXRecords { dictionary_path: Vec, }, + /// V9 (additive): mark an entity reference-only (plugin-owned display data). + SetReferenceOnly { + handle: Handle, + reference_only: bool, + }, } /// Responses the host sends back for `PluginRequest`. @@ -272,6 +277,8 @@ pub enum PluginResponse { XRecordDeleteResult(Result), /// V8 (additive): XRecord names under a dictionary path. XRecordNames(Result, String>), + /// V9 (additive): outcome of the reference-only designation. + SetReferenceOnlyResult(Result<(), String>), } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 874a44ab1..894dc5848 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -151,5 +151,11 @@ pub fn handle_plugin_request( let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect(); PluginResponse::XRecordNames(host.list_xrecords(&path)) } + SetReferenceOnly { + handle, + reference_only, + } => { + PluginResponse::SetReferenceOnlyResult(host.set_reference_only(handle, reference_only)) + } } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 93b080377..aaf2f448f 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -987,6 +987,23 @@ impl HostApi for V4PluginHostApi { Err(error) => Err(error.to_string()), } } + + fn set_reference_only(&mut self, handle: Handle, reference_only: bool) -> Result<(), String> { + match self.request(PluginRequest::SetReferenceOnly { + handle, + reference_only, + }) { + Ok(PluginResponse::SetReferenceOnlyResult(result)) => { + if result.is_ok() { + self.document_cache = OnceCell::new(); + } + result + } + Ok(PluginResponse::Error(error)) => Err(error), + Ok(other) => Err(format!("unexpected reference-only response: {other:?}")), + Err(error) => Err(error.to_string()), + } + } } #[cfg(test)] diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 450afbf99..cf98d0e63 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -519,6 +519,47 @@ mod tests { } } + #[test] + fn reference_only_request_and_response_roundtrip() { + // The V9 request marks an entity reference-only and the response + // carries the operation's outcome. + let bytes = bincode::serialize(&PluginRequest::SetReferenceOnly { + handle: codec::Handle::new(0x2A), + reference_only: true, + }) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginRequest::SetReferenceOnly { handle, reference_only } if + handle == codec::Handle::new(0x2A) && reference_only) + ); + let bytes = bincode::serialize(&PluginRequest::SetReferenceOnly { + handle: codec::Handle::new(0x2A), + reference_only: false, + }) + .unwrap(); + assert!(matches!( + bincode::deserialize::(&bytes).unwrap(), + PluginRequest::SetReferenceOnly { + reference_only: false, + .. + } + )); + let bytes = bincode::serialize(&PluginResponse::SetReferenceOnlyResult(Ok(()))).unwrap(); + assert!(matches!( + bincode::deserialize::(&bytes).unwrap(), + PluginResponse::SetReferenceOnlyResult(Ok(())) + )); + let bytes = bincode::serialize(&PluginResponse::SetReferenceOnlyResult(Err( + "entity is on a locked layer".to_owned(), + ))) + .unwrap(); + assert!( + matches!(bincode::deserialize::(&bytes).unwrap(), + PluginResponse::SetReferenceOnlyResult(Err(error)) if error == "entity is on a locked layer") + ); + } + #[test] fn unknown_notification_deserializes_as_unknown() { // Build a payload with an unknown discriminant (99) followed by some diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 6a40f9ce3..27c371d73 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -24,8 +24,11 @@ /// undoable replacement of a set of existing entities. v8 appends name-addressed /// XRecord object CRUD under named-object-dictionary paths /// (`HostApi::create_xrecord` / `read_xrecord` / `update_xrecord` / -/// `delete_xrecord` / `list_xrecords`). -pub const API_VERSION: u32 = 8; +/// `delete_xrecord` / `list_xrecords`). v9 appends the reference-only entity +/// designation (`HostApi::set_reference_only`): display-only geometry a plugin +/// still owns and refreshes while the built-in edit paths treat it like a +/// locked layer. +pub const API_VERSION: u32 = 9; /// Oldest plugin API major the current host still loads. This keeps previously /// compiled cdylibs usable as long as their vtable layout is a prefix of the diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 00d437ab9..c2995e4cc 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -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). diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index e1e42d3f1..487033337 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -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`, diff --git a/src/app/automation.rs b/src/app/automation.rs index a6e10ddba..816873df2 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 } @@ -725,6 +729,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"))] @@ -763,6 +770,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() { @@ -1739,6 +1750,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/block_editor.rs b/src/app/block_editor.rs index 065f18555..58b95550c 100644 --- a/src/app/block_editor.rs +++ b/src/app/block_editor.rs @@ -223,6 +223,7 @@ impl OpenCADStudio { active_layer, structure, parametric_constraints, + reference_designations: Vec::new(), named_parameters, label: format!("Edit block {}", session.block_name), }) diff --git a/src/app/control/sheets.rs b/src/app/control/sheets.rs index aafd39cd8..0ca385253 100644 --- a/src/app/control/sheets.rs +++ b/src/app/control/sheets.rs @@ -466,6 +466,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 bc753b254..406a67c9b 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, @@ -630,6 +648,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(), @@ -703,6 +722,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 @@ -777,6 +804,7 @@ impl HistorySnapshot { .saturating_add(d.named_parameters.as_ref().map_or(0, |(before, after)| { before.len().saturating_add(after.len()).saturating_mul(128) })) + .saturating_add(d.reference_designations.len().saturating_mul(16)) .saturating_add(d.label.len()), HistorySnapshot::ObjectVisibility(v) => v .before @@ -829,6 +857,12 @@ pub(super) struct DeltaSnapshot { /// `document.objects`, so it needs its own channel here rather than /// riding along with `entities`/`structure`. Almost always empty. pub(super) parametric_constraints: Vec, + /// Reference-only designations (API v9) this same command changed — set + /// state on `Scene` (see `scene::reference_only`), recorded as explicit + /// first-touch before/afters so undo/redo restores the designation with + /// the entity it belongs to (e.g. a plugin scope that both erases and + /// re-designates). Almost always empty. + pub(super) reference_designations: Vec, /// Drawing-wide parameter table changed by this transaction. pub(super) named_parameters: Option<( crate::scene::named_parameters::ParameterTable, @@ -903,6 +937,17 @@ pub(super) struct ParametricConstraintsEntryDelta { pub(super) after: crate::scene::parametric_constraints::ParametricConstraintSet, } +/// One reference-only designation's before/after value within an entity delta +/// (API v9). The designation set lives on `Scene`, not in the document, so +/// the value rides its own channel — symmetric like every other delta field, +/// undo applies the before value, redo the after value. +#[derive(Clone)] +pub(super) struct ReferenceDesignationDelta { + pub(super) handle: Handle, + pub(super) before: bool, + pub(super) after: bool, +} + #[derive(Default)] pub(super) struct HistoryState { pub(super) undo_stack: Vec, @@ -918,6 +963,12 @@ pub(super) struct PendingHistorySnapshot { pub(super) dirty_before: bool, pub(super) structure_before: CadDocument, pub(super) recorder: Arc, + /// First-touch before-values of reference-only designations (API v9) + /// mutated inside this plugin scope (see `scene::reference_only`). Set + /// mutations are not document writes, so the scope closes by reading the + /// set's current state as the after-image and keeping entries whose value + /// actually flipped. + pub(super) reference_designations_before: Vec<(Handle, bool)>, /// Kept as an undo step even when nothing changed. pub(super) keep_empty: bool, } diff --git a/src/app/history.rs b/src/app/history.rs index 649135591..1dd3a7aed 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -1,8 +1,8 @@ use super::{ document::{ DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot, - ParametricConstraintsEntryDelta, PendingHistorySnapshot, StructureSnapshot, - TableEntryDelta, + ParametricConstraintsEntryDelta, PendingHistorySnapshot, ReferenceDesignationDelta, + StructureSnapshot, TableEntryDelta, }, OpenCADStudio, }; @@ -226,6 +226,7 @@ impl OpenCADStudio { active_layer: None, structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: label.into(), }; @@ -328,8 +329,26 @@ impl OpenCADStudio { let selected_after: Vec = self.tabs[i].scene.selected.iter().copied().collect(); let dirty_after = self.tabs[i].dirty; let active_layer_after = self.tabs[i].active_layer.clone(); + // The designation set is scene state mutated through the plugin API: + // a scope that (un)marked entities carries the first-touch before + // values here; the after value is whatever the set holds now, and + // only untouched-value entries drop out (an idempotent re-mark never + // got recorded in the first place). + let reference_designations: Vec<_> = pending + .reference_designations_before + .into_iter() + .filter_map(|(handle, before)| { + let after = self.tabs[i].scene.reference_only.is_marked(handle); + (before != after).then_some(ReferenceDesignationDelta { + handle, + before, + after, + }) + }) + .collect(); if !pending.keep_empty && entities.is_empty() + && reference_designations.is_empty() && !structure_changed && pending.selected_before == selected_after && pending.dirty_before == dirty_after @@ -350,6 +369,7 @@ impl OpenCADStudio { structure: structure_changed .then_some(StructureSnapshot::Full(pending.structure_before)), parametric_constraints: Vec::new(), + reference_designations, named_parameters: None, label: pending.label, }; @@ -390,10 +410,33 @@ impl OpenCADStudio { dirty_before, structure_before, recorder, + reference_designations_before: Vec::new(), keep_empty: false, }); } + /// Record the pre-mutation value of a reference-only designation (API v9) + /// into the open plugin pending scope — first touch wins. No-op when no + /// scope is open: like the XRecord methods, a designation change must open + /// its own scope before mutating, so by the time this runs a scope either + /// covers it or the caller owns one. + pub(super) fn record_reference_designation_before_pending( + &mut self, + i: usize, + handle: Handle, + before: bool, + ) { + if let Some(pending) = self.tabs[i].history.pending.as_mut() { + if !pending + .reference_designations_before + .iter() + .any(|(recorded, _)| *recorded == handle) + { + pending.reference_designations_before.push((handle, before)); + } + } + } + /// [`Self::push_undo_snapshot`] for a command that is an undo step even /// when it changes nothing (UNDO and U then name it and undo nothing). pub(super) fn push_undo_mark(&mut self, i: usize, label: impl Into) { @@ -481,6 +524,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::Layers(entries)), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: pending.label, }; @@ -538,6 +582,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::TextStyles(entries)), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: pending.label, }; @@ -595,6 +640,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::DimStyles(entries)), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: pending.label, }; @@ -660,6 +706,7 @@ impl OpenCADStudio { active_layer: None, structure: Some(StructureSnapshot::Objects(entries)), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: pending.label, }; @@ -695,6 +742,7 @@ impl OpenCADStudio { object_handles, }), parametric_constraints: Vec::new(), + reference_designations: Vec::new(), named_parameters: None, label: "STYLE".to_string(), }; @@ -777,8 +825,13 @@ impl OpenCADStudio { pending.label ); } - let (entity_before, object_before, parametric_constraints_before, named_parameters_before) = - rec.into_recorded_images(); + let ( + entity_before, + object_before, + parametric_constraints_before, + named_parameters_before, + reference_designations_before, + ) = rec.into_recorded_images(); let entities: Vec<(Handle, Option>, Option>)> = entity_before .into_iter() @@ -824,6 +877,20 @@ impl OpenCADStudio { let after = self.tabs[i].scene.named_parameters().clone(); (before != after).then_some((before, after)) }); + // Designations recorded by `remove_entities`'s delta path (the + // first-touch before values) close symmetric with the set's current + // state as the after image. + let reference_designations: Vec = reference_designations_before + .into_iter() + .filter_map(|(handle, before)| { + let after = self.tabs[i].scene.reference_only.is_marked(handle); + (before != after).then_some(ReferenceDesignationDelta { + handle, + before, + after, + }) + }) + .collect(); let selected_after = self.tabs[i].scene.selected.iter().copied().collect(); let dirty_after = self.tabs[i].dirty; let mut structure = pending.structure_before.map(StructureSnapshot::Full); @@ -875,6 +942,7 @@ impl OpenCADStudio { active_layer: None, structure, parametric_constraints, + reference_designations, named_parameters, label: pending.label, }; @@ -1029,8 +1097,19 @@ impl OpenCADStudio { } } } - let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo); + let mut changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo); let scene = &mut self.tabs[i].scene; + // Designations: restore the chosen side's value on the scene-owned set + // (the entity images above already applied, so a restored entity is + // present before its designation is). Each actual flip joins the + // returned per-handle change list so the deferred cache reseed and + // geometry bump re-stamp that entity's wires. + for entry in &d.reference_designations { + let value = if undo { entry.before } else { entry.after }; + if scene.set_reference_only_membership(entry.handle, value) { + changes.push((entry.handle, crate::scene::ChangeKind::Modified)); + } + } if !d.parametric_constraints.is_empty() { scene.bump_constraints_epoch(); } diff --git a/src/app/mod.rs b/src/app/mod.rs index ee66dddd5..85d4391f9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1153,6 +1153,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, @@ -4832,6 +4838,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 1bfc64064..0195ab66b 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -107,7 +107,7 @@ impl<'a> HostSession<'a> { *slot = attribute; self.app.tabs[self.tab] .scene - .update_entity(EntityType::Insert(insert)) + .update_entity_plugin(EntityType::Insert(insert)) } pub fn document_view(&mut self) -> Option { @@ -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 = @@ -373,7 +378,7 @@ impl<'a> HostSession<'a> { insert.attributes.push(attribute); if !self.app.tabs[self.tab] .scene - .update_entity(EntityType::Insert(insert)) + .update_entity_plugin(EntityType::Insert(insert)) { return Handle::NULL; } @@ -415,7 +420,7 @@ impl<'a> HostSession<'a> { } let ok = match entity { EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), - entity => self.app.tabs[self.tab].scene.update_entity(entity), + entity => self.app.tabs[self.tab].scene.update_entity_plugin(entity), }; self.app.sync_registered_layers(self.tab); if ok { @@ -473,7 +478,7 @@ impl<'a> HostSession<'a> { // the document exclusively. let updated = match entity { EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), - entity => self.app.tabs[self.tab].scene.update_entity(entity), + entity => self.app.tabs[self.tab].scene.update_entity_plugin(entity), }; assert!(updated); } @@ -519,6 +524,28 @@ impl<'a> HostSession<'a> { Ok(()) } + /// Erase through the plugin path while keeping reference-only designations + /// undoable with the erase (API v9). The designation set is scene state, + /// not a document write: erasing drops the set entry in + /// `Scene::remove_entities`, so its first-touch before-image must join the + /// open plugin scope here — otherwise undoing the erase would restore the + /// entity without its designation. No-op for undesignated handles and when + /// no scope is open (then the erase follows the same no-bookkeeping + /// contract as any other entity write). + fn erase_entities_recording_designations(&mut self, handles: &[Handle]) { + for &handle in handles { + if self.app.tabs[self.tab] + .scene + .reference_only + .is_marked(handle) + { + self.app + .record_reference_designation_before_pending(self.tab, handle, true); + } + } + self.app.tabs[self.tab].scene.erase_entities_plugin(handles); + } + /// Delete the entity with `handle`, keeping the scene's render caches in /// sync. Returns `false` when the entity is absent or on a locked layer /// (which `erase_entities` refuses to remove). @@ -548,7 +575,7 @@ impl<'a> HostSession<'a> { .document() .get_entity(handle) .map(|entity| entity.common().owner_handle); - self.app.tabs[self.tab].scene.erase_entities(&[handle]); + self.erase_entities_recording_designations(&[handle]); let removed = self.document().get_entity(handle).is_none(); if removed { // The document keeps a block record's member list separately from the @@ -1138,7 +1165,7 @@ impl<'a> HostSession<'a> { return Err("the result could not be added".into()); } if delete_source { - self.app.tabs[self.tab].scene.erase_entities(&[source]); + self.erase_entities_recording_designations(&[source]); self.publish_document_view(); } Ok(handle) @@ -1401,7 +1428,7 @@ impl<'a> HostSession<'a> { self.push_undo("Delete layer"); self.document_mut().layers.remove(&name); if !on_layer.is_empty() { - self.app.tabs[self.tab].scene.erase_entities(&on_layer); + self.erase_entities_recording_designations(&on_layer); } self.finish_layer_change(&[name]); Ok(handle) @@ -2256,7 +2283,7 @@ impl<'a> HostSession<'a> { } } if erase_originals { - self.app.tabs[self.tab].scene.erase_entities(&entities); + self.erase_entities_recording_designations(&entities); } self.finish_block_change(); Ok(handle) @@ -3123,6 +3150,76 @@ impl<'a> HostSession<'a> { .collect()) } + /// Mark (or unmark) an entity as reference-only (API v9, additive): + /// plugin-owned display geometry the built-in edit paths treat like a + /// locked-layer entity, while the owning plugin keeps full write access + /// 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 on the `DocumentOpened` notification, which makes the + /// in-memory designation fully equivalent to a persisted one for its use + /// case. + /// + /// Every refusal — absent entity, locked layer — is decided before the + /// undo scope is touched, so a failure changes nothing. A call that would + /// not change the current designation succeeds without recording an undo + /// step, without bumping the geometry epoch and without publishing — the + /// idempotence that terminates the re-mark loop driven by the + /// document-changed dispatch. Undo composition: a change inside a + /// plugin-opened `push_undo` scope joins it (see + /// [`HostSession::ensure_xrecord_undo_scope`]); a scope-less call is one + /// undo step of its own. The set is scene state, not a document write, so + /// the undo delta restores it through the explicit designation channel + /// (`DeltaSnapshot::reference_designations`). + pub fn set_reference_only( + &mut self, + handle: Handle, + reference_only: bool, + ) -> Result<(), String> { + // Refusals precede any change (and any undo step). + if self.document().get_entity(handle).is_none() { + return Err(format!("entity {handle:?} does not exist")); + } + if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + return Err(format!("entity {handle:?} is on a locked layer")); + } + if self.app.tabs[self.tab].scene.is_reference_only(handle) == reference_only { + return Ok(()); + } + let own_scope = self.ensure_xrecord_undo_scope("Set Reference-Only"); + // First-touch before-image for the pending scope's designation + // channel, recorded before the set changes (see + // `Scene::record_undo_reference_designation_before`). + let before = self.app.tabs[self.tab] + .scene + .reference_only + .is_marked(handle); + self.app + .record_reference_designation_before_pending(self.tab, handle, before); + self.app.tabs[self.tab] + .scene + .set_reference_only_membership(handle, reference_only); + debug_assert!( + self.app.tabs[self.tab] + .scene + .reference_only + .is_marked(handle) + == reference_only, + "designation state was checked before the mutation" + ); + // The wires carry the click-through stamp baked at tessellation, so a + // designation change must rebuild them: bump_geometry clears the + // tessellation memos and resident wire sets exactly like an entity + // edit would. + self.bump_geometry(); + self.set_dirty(); + self.publish_document_view(); + if own_scope { + self.app.finish_pending_history(self.tab); + } + Ok(()) + } + /// Resolve an existing XRecord to its containing dictionary, its validated /// name and its handle, after validating the path, the name and the target /// kind. @@ -3526,6 +3623,9 @@ impl HostApi for HostSession<'_> { fn list_xrecords(&self, dictionary_path: &[&str]) -> Result, String> { self.list_xrecords(dictionary_path) } + fn set_reference_only(&mut self, handle: Handle, reference_only: bool) -> Result<(), String> { + self.set_reference_only(handle, reference_only) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) @@ -13379,6 +13479,304 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) assert!(!host.update_entity(EntityType::Point(ghost))); } + fn reference_test_line(end_x: f64, y: f64, handle: Handle) -> EntityType { + let mut line = codec::entities::Line::from_points( + codec::types::Vector3::new(0.0, y, 0.0), + codec::types::Vector3::new(end_x, y, 0.0), + ); + line.common.handle = handle; + EntityType::Line(line) + } + + #[test] + fn reference_only_refusals_record_no_undo_step() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + // Marking an absent entity refuses. + assert!(host.set_reference_only(Handle::new(999_999), true).is_err()); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + // A locked layer is as unwritable for the designation as for every + // other plugin write. + host.app.tabs[0] + .scene + .document + .layers + .get_mut("0") + .map(|layer| layer.lock()); + assert!(host.set_reference_only(handle, true).is_err()); + host.app.tabs[0] + .scene + .document + .layers + .get_mut("0") + .map(|layer| layer.unlock()); + assert!( + !host.app.tabs[0].scene.is_reference_only(handle), + "refusals must not partially apply the designation" + ); + assert_eq!( + host.app.tabs[0].history.undo_stack.len(), + 0, + "refusals record no undo step" + ); + // An idempotent re-mark succeeds without recording another step. + host.set_reference_only(handle, true).unwrap(); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), 1); + host.set_reference_only(handle, true).unwrap(); + assert_eq!( + host.app.tabs[0].history.undo_stack.len(), + 1, + "an idempotent re-mark records no undo step" + ); + } + + #[test] + fn reference_only_designation_composes_undo_like_entity_writes() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + // A scope-less designation is one undo step of its own. + host.set_reference_only(handle, true).unwrap(); + host.set_reference_only(handle, false).unwrap(); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), 2); + + // Undo reverts the unmark, redo re-applies it. + drop(host); + app.undo_steps(1); + let host = HostSession::new(&mut app, 0); + assert!(host.app.tabs[0].scene.is_reference_only(handle)); + drop(host); + app.redo_steps(1); + let mut host = HostSession::new(&mut app, 0); + assert!(!host.app.tabs[0].scene.is_reference_only(handle)); + + // Inside a plugin-opened push_undo scope the designation joins the + // scope: an entity write plus two markings is ONE step under the + // plugin's label. + host.push_undo("CVI: refresh"); + let preview = host.add_entity(EntityType::Point(Point::at(codec::types::Vector3::new( + 3.0, 0.0, 0.0, + )))); + host.set_reference_only(preview, true).unwrap(); + host.set_reference_only(handle, true).unwrap(); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + assert_eq!(app.tabs[0].history.undo_stack[2].label(), "CVI: refresh"); + + // Undoing the plugin operation reverts it whole. + app.undo_steps(1); + let host = HostSession::new(&mut app, 0); + assert!( + host.app.tabs[0] + .scene + .document + .get_entity(preview) + .is_none(), + "undoing the scope removed the entity created inside it" + ); + assert!( + !host.app.tabs[0].scene.is_reference_only(handle), + "undoing the scope restored the designation's prior state" + ); + } + + #[test] + fn reference_only_designation_is_not_written_to_dwg_or_dxf() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + host.set_reference_only(handle, true).unwrap(); + // The designation lives in the scene's in-memory set only. + assert!(host.app.tabs[0].scene.is_reference_only(handle)); + + // The host writes nothing of its own into the document: no APPID + // registration, no marker XDATA on the designated entity. + assert!(host.document().app_ids.get("OCS").is_none()); + assert!(host + .document() + .get_entity(handle) + .expect("designated entity") + .common() + .extended_data + .get_record("OCS") + .is_none()); + + let contains_marker = + |bytes: &[u8]| bytes.windows(15).any(|window| window == b"REFERENCE_ONLY"); + + // DWG: the marker string appears nowhere in the saved bytes, and a + // reopened file carries neither the host APPID nor the marker record. + let dwg = codec::DwgWriter::write_to_vec(host.document()).unwrap(); + assert!( + !contains_marker(&dwg), + "DWG save must not contain the designation marker" + ); + let reloaded = crate::io::load_bytes("reference-only.dwg", dwg).unwrap(); + assert!(reloaded.app_ids.get("OCS").is_none()); + assert!(reloaded + .get_entity(handle) + .expect("the line survives the round trip") + .common() + .extended_data + .get_record("OCS") + .is_none()); + assert!( + !contains_marker(&codec::DwgWriter::write_to_vec(&reloaded).unwrap()), + "a re-save of the reopened file stays marker-free" + ); + + // DXF: same contract for the text format. + let dxf = codec::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); + assert!( + !contains_marker(&dxf), + "DXF save must not contain the designation marker" + ); + let reloaded = crate::io::load_bytes("reference-only.dxf", dxf).unwrap(); + assert!(reloaded.app_ids.get("OCS").is_none()); + assert!(reloaded + .get_entity(handle) + .expect("the line survives the round trip") + .common() + .extended_data + .get_record("OCS") + .is_none()); + } + + #[test] + fn erasing_a_designated_entity_undoes_designation_with_it() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + host.set_reference_only(handle, true).unwrap(); + + // The plugin erase path removes a designated entity inside a scope: + // entity and set entry leave together. + host.push_undo("CVI: replace the underlying model"); + host.remove_entity(handle); + drop(host); + app.finish_pending_history(0); + let host = HostSession::new(&mut app, 0); + assert!(host.app.tabs[0].scene.document.get_entity(handle).is_none()); + assert!(!host.app.tabs[0].scene.is_reference_only(handle)); + drop(host); + + // Undoing the erase restores the entity AND its designation: the set + // mutation rode the scope as an explicit designation channel. + app.undo_steps(1); + let host = HostSession::new(&mut app, 0); + assert!( + host.app.tabs[0].scene.document.get_entity(handle).is_some(), + "undo restored the erased entity" + ); + assert!( + host.app.tabs[0].scene.is_reference_only(handle), + "undo restored the designation together with its entity" + ); + } + + #[test] + fn user_paths_refuse_and_plugin_paths_write_reference_only_entities() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + host.set_reference_only(handle, true).unwrap(); + let plain = host.add_entity(reference_test_line(10.0, 5.0, Handle::NULL)); + + // Grips: the reference-only entity contributes none; the editable one + // keeps its own. + host.set_selection(&[handle]).unwrap(); + host.app.refresh_selected_grips(); + assert!( + host.app.tabs[0].selected_grips.is_empty(), + "reference-only entities contribute no grips" + ); + host.set_selection(&[plain]).unwrap(); + host.app.refresh_selected_grips(); + assert!(!host.app.tabs[0].selected_grips.is_empty()); + + // The user in-place update refuses; the user batch skips only the + // reference-only member; the plugin-owned variants write through. + assert!(!host.app.tabs[0] + .scene + .update_entity(reference_test_line(20.0, 0.0, handle))); + let applied = host.app.tabs[0].scene.update_entities(vec![ + reference_test_line(20.0, 0.0, handle), + reference_test_line(20.0, 5.0, plain), + ]); + assert_eq!( + applied, 1, + "the user batch applies only the editable entity" + ); + let end_x = |scene: &crate::scene::Scene, h: Handle| match scene.document.get_entity(h) { + Some(EntityType::Line(line)) => line.end.x, + _ => panic!("entity {h:?} must be a line"), + }; + let scene = &host.app.tabs[0].scene; + assert_eq!( + end_x(scene, handle), + 10.0, + "the user paths refused the write" + ); + assert_eq!(end_x(scene, plain), 20.0); + // The plugin-owned variant writes through. A real plugin refresh + // clones the live entity (designation included), edits it, and + // replaces it — so model that flow instead of a bare synthetic. + let mut moved = match host.app.tabs[0].scene.document.get_entity(handle) { + Some(entity) => entity.clone(), + _ => unreachable!("the entity exists"), + }; + if let EntityType::Line(line) = &mut moved { + line.end.x = 30.0; + } + assert!(host.app.tabs[0].scene.update_entity_plugin(moved)); + let scene = &host.app.tabs[0].scene; + assert_eq!( + end_x(scene, handle), + 30.0, + "the plugin-owned update writes through" + ); + assert!( + scene.is_reference_only(handle), + "a refresh-style plugin write preserves the designation" + ); + + // User erase skips reference-only entities; the plugin erase path + // removes them. + host.app.tabs[0].scene.erase_entities(&[handle]); + assert!( + host.app.tabs[0].scene.document.get_entity(handle).is_some(), + "user erase skips reference-only entities" + ); + host.app.tabs[0].scene.erase_entities_plugin(&[handle]); + assert!( + host.app.tabs[0].scene.document.get_entity(handle).is_none(), + "the plugin erase path removes reference-only entities" + ); + assert!(host.app.tabs[0].scene.document.get_entity(plain).is_some()); + } + + #[test] + fn select_all_visible_skips_reference_only_entities() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + host.set_reference_only(handle, true).unwrap(); + let plain = host.add_entity(reference_test_line(10.0, 5.0, Handle::NULL)); + + assert_eq!(host.app.tabs[0].scene.select_all_visible(), 1); + let selected = host.app.tabs[0].scene.selected_handles_in_order(); + assert_eq!(selected, vec![plain]); + } + #[test] fn add_and_update_auto_register_novel_layers_with_real_handles() { // A plugin adds/edits an entity naming a layer no LAYER command ever diff --git a/src/app/properties.rs b/src/app/properties.rs index 45450df84..747629f0d 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2665,10 +2665,14 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} /// reachable from Options and from the sysvar of the same name. pub(super) fn refresh_selected_grips(&mut self) { let i = self.active_tab; + // Reference-only targets are cancelled like locked-layer ones: the + // plugin that owns the display geometry may have just designated the + // entity, and a drag that edits it would be overwritten on refresh. let locked_active_grip = self.tabs[i].active_grip.as_ref().is_some_and(|grip| { - grip.targets - .iter() - .any(|target| self.tabs[i].scene.is_layer_locked(target.handle)) + grip.targets.iter().any(|target| { + self.tabs[i].scene.is_layer_locked(target.handle) + || self.tabs[i].scene.is_reference_only(target.handle) + }) }); if locked_active_grip { self.cancel_active_grip_edit(); @@ -2682,7 +2686,8 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); let selected = self.tabs[i].scene.selected_entities(); let single_handle = (selected.len() == 1 - && !self.tabs[i].scene.is_layer_locked(selected[0].0)) + && !self.tabs[i].scene.is_layer_locked(selected[0].0) + && !self.tabs[i].scene.is_reference_only(selected[0].0)) .then(|| selected[0].0); let mut grips = Vec::new(); let mut handles = Vec::new(); @@ -2695,7 +2700,9 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} selected }; for (handle, entity) in selected { - if self.tabs[i].scene.is_layer_locked(handle) { + if self.tabs[i].scene.is_layer_locked(handle) + || self.tabs[i].scene.is_reference_only(handle) + { continue; } let contextual = crate::scene::annotative::entity_for_annotation_context( diff --git a/src/app/record_api.rs b/src/app/record_api.rs index d2b91e272..af7d10cfb 100644 --- a/src/app/record_api.rs +++ b/src/app/record_api.rs @@ -226,7 +226,7 @@ fn record( value } -fn entity_record(entity: &codec::EntityType) -> Result { +fn entity_record(entity: &codec::EntityType, reference_only: bool) -> Result { let (kind, properties) = enum_parts(entity)?; let mut value = record( "entities", @@ -245,6 +245,9 @@ fn entity_record(entity: &codec::EntityType) -> Result { "record_type".into(), json!(crate::entities::names::dxf_name(entity)), ); + // Scene-scoped designation (API v9): the set membership of this entity's + // handle, resolved by the caller from the live scene. + object.insert("reference_only".into(), json!(reference_only)); Ok(value) } @@ -305,12 +308,13 @@ fn map_records( .collect() } -fn collection_records( - document: &codec::CadDocument, - collection: &str, -) -> Result, String> { +fn collection_records(scene: &crate::scene::Scene, collection: &str) -> Result, String> { + let document = &scene.document; match collection { - "entities" => document.entities().map(entity_record).collect(), + "entities" => document + .entities() + .map(|entity| entity_record(entity, scene.is_reference_only(entity.common().handle))) + .collect(), "objects" => { let mut objects: Vec<_> = document.objects.iter().collect(); objects.sort_by_key(|(handle, _)| handle.value()); @@ -1385,7 +1389,7 @@ impl OpenCADStudio { } let definitions = referenced_definitions(&root_type); let runtime_schema = collection.and_then(|collection| { - collection_records(&self.tabs[self.active_tab].scene.document, collection) + collection_records(&self.tabs[self.active_tab].scene, collection) .ok()? .into_iter() .find(|record| { @@ -1429,20 +1433,20 @@ impl OpenCADStudio { } pub(super) fn record_query(&self, request: &Value) -> Value { - let document = &self.tabs[self.active_tab].scene.document; + let scene = &self.tabs[self.active_tab].scene; let Some(collection) = request["collection"].as_str() else { return self.record_capabilities(); }; let mut records = Vec::new(); if collection == "all" { for (name, _) in COLLECTIONS { - match collection_records(document, name) { + match collection_records(scene, name) { Ok(mut values) => records.append(&mut values), Err(error) => return failure("serialization_failed", error), } } } else { - match collection_records(document, collection) { + match collection_records(scene, collection) { Ok(values) => records = values, Err(error) => return failure("unknown_collection", error), } @@ -1506,6 +1510,12 @@ impl OpenCADStudio { if self.tabs[i].scene.is_layer_locked(handle) { return Err(failure("layer_locked", "entity is on a locked layer")); } + if self.tabs[i].scene.is_reference_only(handle) { + return Err(failure( + "reference_only", + "entity is reference-only (plugin-owned display data)", + )); + } let source = self.tabs[i] .scene .document @@ -1692,6 +1702,59 @@ mod tests { app.automation_op(&request.to_string()) } + #[test] + fn entities_records_expose_the_reference_only_designation() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + + let handle = app.tabs[i].scene.add_entity(EntityType::Line( + codec::entities::Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 0.0, 0.0), + ), + )); + // The designation is plugin-mutated scene state: mark through the + // same host API a plugin uses, so the read surface reflects the + // live set rather than any stored marker. + fn mark(app: &mut OpenCADStudio, handle: Handle, on: bool) { + let i = app.active_tab; + crate::app::plugin_host::HostSession::new(app, i) + .set_reference_only(handle, on) + .expect("mark through the plugin host API"); + } + + // The read surface reports the designation alongside the record. + mark(&mut app, handle, true); + let records = app.record_query(&json!({"collection":"entities"})); + assert_eq!(records["count"], 1); + assert_eq!( + records["records"][0]["handle"], + json!(format!("{:X}", handle.value())) + ); + assert_eq!(records["records"][0]["reference_only"], json!(true)); + + // The MCP write path treats a reference-only entity like a locked + // layer: the edit is refused, honestly. + let refused = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"entities", + "handle": format!("{:X}", handle.value()), + "updates":[{"path":"/end/x","value":20.0}], + }), + "reference-only", + ); + assert_eq!(refused["ok"], json!(false)); + assert_eq!(refused["code"], json!("reference_only")); + + // Clearing the designation is reported too. + mark(&mut app, handle, false); + let records = app.record_query(&json!({"collection":"entities"})); + assert_eq!(records["records"][0]["reference_only"], json!(false)); + } + #[test] fn records_query_and_edit_every_mutable_record_family() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 5405968bc..6e34a9965 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2276,6 +2276,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 0fe152e57..6b4d88759 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -312,6 +312,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); } @@ -331,6 +336,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 { @@ -577,6 +611,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/app/update/viewport.rs b/src/app/update/viewport.rs index dc9c99f78..faa6ce7a2 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -736,6 +736,9 @@ impl OpenCADStudio { bounds, candidate_handles.as_ref(), )); + // Reference-only entities (API v9) are plugin-owned display geometry: + // a box/window selection must not pick them. + handles.retain(|h| !scene.is_reference_only(*h)); timing.hit = elapsed(t_hit); (handles, timing) } @@ -5308,6 +5311,7 @@ properties={:.1}ms picked={}", .into_iter() .filter_map(|s| Scene::handle_from_wire_name(s)) .filter(|&h| self.tabs[i].scene.passes_selection_filter(h)) + .filter(|&h| !self.tabs[i].scene.is_reference_only(h)) .collect(); if cands.len() >= 2 { // Overlap: open the list box at the cursor. diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 07f2292b6..fd021e0f2 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3598,6 +3598,7 @@ fn apply_dimension_jog( } pub trait DimensionTess { + #[allow(clippy::too_many_arguments)] fn tessellate( &self, document: &CadDocument, @@ -3611,10 +3612,12 @@ pub trait DimensionTess { bg_color: [f32; 4], view_aabb: Option<[f32; 4]>, world_per_pixel: Option, + reference_only: &rustc_hash::FxHashSet, ) -> Vec; } impl DimensionTess for Dimension { + #[allow(clippy::too_many_arguments)] fn tessellate( &self, document: &CadDocument, @@ -3628,6 +3631,7 @@ impl DimensionTess for Dimension { bg_color: [f32; 4], view_aabb: Option<[f32; 4]>, world_per_pixel: Option, + reference_only: &rustc_hash::FxHashSet, ) -> Vec { tessellate_dimension_inner( document, @@ -3642,10 +3646,12 @@ impl DimensionTess for Dimension { bg_color, view_aabb, world_per_pixel, + reference_only, ) } } +#[allow(clippy::too_many_arguments)] fn tessellate_dimension_inner( document: &CadDocument, handle: Handle, @@ -3662,6 +3668,7 @@ fn tessellate_dimension_inner( bg_color: [f32; 4], view_aabb: Option<[f32; 4]>, world_per_pixel: Option, + reference_only: &rustc_hash::FxHashSet, ) -> Vec { if let Dimension::Arc(arc) = dim { if let Some((local_arc, normal)) = arc_dimension_in_ocs(arc) { @@ -3678,6 +3685,7 @@ fn tessellate_dimension_inner( bg_color, None, world_per_pixel, + reference_only, ); for wire in &mut wires { map_wire_ocs_to_wcs(wire, normal); @@ -3700,6 +3708,7 @@ fn tessellate_dimension_inner( bg_color, None, world_per_pixel, + reference_only, ); for wire in &mut wires { map_wire_ocs_to_wcs(wire, normal); @@ -3721,6 +3730,7 @@ fn tessellate_dimension_inner( bg_color, None, world_per_pixel, + reference_only, ); for wire in &mut wires { map_wire_ocs_to_wcs(wire, normal); @@ -4420,6 +4430,7 @@ fn tessellate_dimension_inner( view_aabb, world_per_pixel, text_color, + reference_only, ); for mut w in text_wires { w.name = name.clone(); @@ -4437,6 +4448,7 @@ fn tessellate_dimension_inner( view_aabb, world_per_pixel, text_color, + reference_only, ); for mut w in tol_wires { w.name = name.clone(); @@ -8890,6 +8902,7 @@ mod linear_transform_tests { [0.0, 0.0, 0.0, 1.0], None, None, + &rustc_hash::FxHashSet::default(), ); let mut points = Vec::new(); for wire in &wires { @@ -9221,6 +9234,7 @@ mod layout_parity_tests { [0.0, 0.0, 0.0, 1.0], None, None, + &rustc_hash::FxHashSet::default(), ); let mut points = Vec::new(); let mut fills = 0; diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index ad4ccf5b4..5b92f8494 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -2344,6 +2344,7 @@ impl MultiLeaderTess for MultiLeader { depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, 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())) +} diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 2b8e3fba4..7426b988c 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -792,6 +792,9 @@ fn tessellate_sub_local( // top-level entities, applied per-defn. let wires_out = if let EntityType::Dimension(dimension) = sub { use crate::entities::dimension::DimensionTess; + // Block-definition members never carry the reference-only + // designation (it targets top-level geometry — see the API v9 + // known-limit note), so the block cache tessellates with an empty set. dimension.tessellate( doc, h, @@ -804,6 +807,7 @@ fn tessellate_sub_local( bg_color, None, None, + &HashSet::default(), ) } else { tessellate::tessellate( @@ -1840,6 +1844,7 @@ impl Batches { depth_override: b.local_depth, display_visible: !b.hide_unselected || selected, snap_only: false, + reference_only: false, plot_visible: b.plot_visible, fill_is_3d: b.fill_is_3d, fill_is_2d_solid: b.fill_is_2d_solid, diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 1edecafdd..87f2630b3 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -473,6 +473,7 @@ fn section_symbol_wires( /// so it picks up the standard text LOD ladder (baseline / greek / full), /// then re-color the returned wires with the dimension's resolved text colour /// (so DIMCLRT / DIMSTYLE colours win over the synthetic Text's defaults). +#[allow(clippy::too_many_arguments)] pub(crate) fn tessellate_entity_dim_text( document: &codec::CadDocument, selected: &HashSet, @@ -483,6 +484,7 @@ pub(crate) fn tessellate_entity_dim_text( view_aabb: Option<[f32; 4]>, world_per_pixel: Option, text_color: [f32; 4], + reference_only: &HashSet, ) -> Vec { let mut wires = tessellate_entity( document, @@ -496,6 +498,7 @@ pub(crate) fn tessellate_entity_dim_text( view_aabb, world_per_pixel, false, + reference_only, ); for w in &mut wires { // Synth dim text carries no real entity colour — paint everything @@ -508,6 +511,7 @@ pub(crate) fn tessellate_entity_dim_text( } wires } +#[allow(clippy::too_many_arguments)] pub(crate) fn tessellate_entity( document: &codec::CadDocument, selected: &HashSet, @@ -520,6 +524,7 @@ pub(crate) fn tessellate_entity( view_aabb: Option<[f32; 4]>, world_per_pixel: Option, paper_space: bool, + reference_only: &HashSet, ) -> Vec { let mut wires = tessellate_entity_inner( document, @@ -533,6 +538,7 @@ pub(crate) fn tessellate_entity( view_aabb, world_per_pixel, paper_space, + reference_only, ); let layer_plottable = document @@ -547,8 +553,21 @@ pub(crate) fn tessellate_entity( } } + // Reference-only entities (API v9) render and snap normally but must not + // be picked by the built-in edit paths; the pick-side wire filters in + // `scene::pick::hit_test` and the selection-gather retains consume the + // flag. The stamp is driven by the scene's in-memory designation set — + // outside `Scene` this stays pure data, so the parallel tessellation + // workers only need the handle set, not scene access. + if reference_only.contains(&e.common().handle) { + for wire in &mut wires { + wire.reference_only = true; + } + } + wires } +#[allow(clippy::too_many_arguments)] fn tessellate_entity_inner( document: &codec::CadDocument, selected: &HashSet, @@ -567,6 +586,10 @@ fn tessellate_entity_inner( // Retained through recursive INSERT expansion; PSLTSCALE itself is applied // by the viewport's GPU uniform so it never changes resident wire content. _paper_space: bool, + // The scene's reference-only designation set (API v9): drives the + // `WireModel::reference_only` stamp for dimensions' synthesis paths that + // bypass the wrapper's post-stamp. + reference_only: &HashSet, ) -> Vec { let contextual = crate::scene::annotative::entity_for_annotation_context( document, @@ -1019,6 +1042,7 @@ fn tessellate_entity_inner( bg_color, view_aabb, world_per_pixel, + reference_only, ); for w in &mut wires { w.aci = aci; diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 24d6c8ab8..3e0ba8c04 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -1808,6 +1808,7 @@ pub fn tessellate( depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, @@ -1974,6 +1975,7 @@ pub fn tessellate( depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index c51da0c15..bbb40ae90 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -2279,7 +2279,7 @@ mod tests { scene.sync_diameter_association_angle(dimension); let recording = scene.take_undo_recording().expect("undo recording"); - let (_, objects, _, _) = recording.into_recorded_images(); + let (_, objects, _, _, _) = recording.into_recorded_images(); assert!(objects .iter() .any(|(handle, before)| *handle == association && before.is_some())); diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 741400e9d..d60c0130c 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -519,23 +519,47 @@ impl Scene { /// existing entity. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn update_entity(&mut self, entity: EntityType) -> bool { - let mut changed = Vec::with_capacity(1); - let mut block_change = false; - if !self.update_entity_impl(entity, &mut changed, &mut block_change) { - return false; - } - self.publish_entity_updates(changed, block_change); - true + self.update_entities_policy(vec![entity], false) == 1 } /// Batched variant of [`update_entity`]: writes every replacement, then /// issues a single geometry bump for the whole set instead of one bump per /// entity. pub fn update_entities(&mut self, updates: Vec) -> usize { + self.update_entities_policy(updates, false) + } + + /// Like [`update_entity`](Self::update_entity) but without the + /// reference-only refusal. The plugin owns reference-only entities and + /// keeps full write access through the plugin host API, so a plugin's own + /// refresh of derived display geometry must not be refused. Locked-layer + /// policy stays enforced either way. + pub fn update_entity_plugin(&mut self, entity: EntityType) -> bool { + self.update_entities_policy(vec![entity], true) == 1 + } + + /// Like [`update_entities`](Self::update_entities) but without the + /// reference-only refusal (see [`update_entity_plugin`](Self::update_entity_plugin)). + pub fn update_entities_plugin(&mut self, updates: Vec) -> usize { + self.update_entities_policy(updates, true) + } + + /// Shared write path of the four `update_entity*` entry points. Locked + /// layers always refuse (inside `update_entity_impl`); reference-only + /// entities refuse every caller except the plugin-owned `_plugin` + /// variants, mirroring the `remove_entities` edit-policy split. + fn update_entities_policy( + &mut self, + updates: Vec, + allow_reference_only: bool, + ) -> usize { let mut moved = 0usize; let mut changed = Vec::with_capacity(updates.len()); let mut block_change = false; for entity in updates { + if !allow_reference_only && self.is_reference_only(entity.common().handle) { + continue; + } if self.update_entity_impl(entity, &mut changed, &mut block_change) { moved += 1; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 3a137a715..a1dc68e23 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -36,6 +36,8 @@ pub enum BlockScalePolicy { mod block_base_point_tests; mod boundary; mod camera_ops; +#[cfg(test)] +mod reference_only_tests; pub use camera_ops::{add_background, copy_background, erase_background}; pub(crate) mod centerline; pub(crate) mod centermark; @@ -61,6 +63,7 @@ mod page_setup; mod paper; mod preview; mod project; +pub mod reference_only; mod scene_markers; mod selection; pub mod viewport_dimension_pick; @@ -84,6 +87,10 @@ pub(crate) use convert::tess::{ /// Keep construction-history curves in the same per-entity tessellation memo /// as their parent, so navigation never rebuilds them on every frame. +/// +/// `reference_only` is the scene's designation set: its membership decides +/// whether the produced wires carry the `WireModel::reference_only` click- +/// through stamp (see `scene::reference_only`). #[allow(clippy::too_many_arguments)] pub(crate) fn tessellate_entity( document: &CadDocument, @@ -97,6 +104,7 @@ pub(crate) fn tessellate_entity( view_aabb: Option<[f32; 4]>, world_per_pixel: Option, paper_space: bool, + reference_only: &HashSet, ) -> Vec { let mut wires = convert::tess::tessellate_entity( document, @@ -110,6 +118,7 @@ pub(crate) fn tessellate_entity( view_aabb, world_per_pixel, paper_space, + reference_only, ); if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) { let handle = entity.common().handle; @@ -161,6 +170,7 @@ pub(crate) fn tessellate_entity( view_aabb, world_per_pixel, paper_space, + reference_only, ) }; for wire in &mut construction { @@ -760,6 +770,13 @@ pub struct UndoRecording { >, parametric_constraints_order: Vec, named_parameters_before: Option, + /// First-touch before-images of reference-only designations (API v9) + /// changed by the command — almost always empty. The designation set + /// lives on `Scene`, not in the document, so like + /// `parametric_constraints_before` it needs its own channel. Erasing a + /// designated entity drops its set entry here so undo restores entity + /// and designation together. + reference_designations_before: Vec<(Handle, bool)>, poisoned: bool, } @@ -775,6 +792,7 @@ impl UndoRecording { && self.object_order.is_empty() && self.parametric_constraints_order.is_empty() && self.named_parameters_before.is_none() + && self.reference_designations_before.is_empty() } /// Consume the recording directories in deterministic first-touch @@ -792,6 +810,7 @@ impl UndoRecording { parametric_constraints::ParametricConstraintSet, )>, Option, + Vec<(Handle, bool)>, ) { let entities = self .order @@ -812,11 +831,13 @@ impl UndoRecording { .map(|before| (scope, before)) }) .collect(); + let reference_designations = std::mem::take(&mut self.reference_designations_before); ( entities, objects, parametric_constraints, self.named_parameters_before, + reference_designations, ) } @@ -2138,6 +2159,11 @@ pub struct Scene { pub selected_constraint: Option, /// Session-only ISOLATEOBJECTS / HIDEOBJECTS state. Never written to DWG/DXF. pub object_isolation: ObjectIsolationState, + /// Scene-scoped reference-only designations (API v9): plugin-owned + /// display geometry the built-in edit paths refuse. In-memory only, + /// keyed by handle, lives and dies with this scene — never written to + /// DWG/DXF (see `scene::reference_only`). + pub(crate) reference_only: reference_only::ReferenceOnlyDesignation, /// Entity handles temporarily removed from the base render while an /// interactive grip preview draws their live replacement. /// Separate from object isolation so a grip can never activate the @@ -2782,6 +2808,7 @@ impl Scene { selected_order: Vec::new(), selected_constraint: None, object_isolation: ObjectIsolationState::default(), + reference_only: Default::default(), preview_hidden: HashSet::default(), command_preview_hidden: HashSet::default(), refedit_keep: None, @@ -3434,6 +3461,26 @@ impl Scene { } } + /// Record a reference-only designation's value before its first change + /// within the open delta recording (API v9). The designation set lives on + /// `Scene`, outside the document, so it needs its own channel — like + /// the parametric-constraint directory above — for undo to restore the + /// designation together with the entity it belongs to. First touch wins. + pub(crate) fn record_undo_reference_designation_before(&mut self, handle: Handle) { + if let Some(recording) = self.undo_recording.as_mut() { + if !recording + .reference_designations_before + .iter() + .any(|(recorded, _)| *recorded == handle) + { + let before = self.reference_only.is_marked(handle); + recording + .reference_designations_before + .push((handle, before)); + } + } + } + /// Flag the open recording as touching non-entity state (a new layer, a /// group edit, a `*D` block record) that a pure-entity delta cannot /// restore. The app's per-command predicate keeps this from firing. @@ -3491,6 +3538,13 @@ impl Scene { } } + // History replay can apply an entity removal the designation channels + // predate (an "added" side rolled back). Sweep so the set never keeps + // an entry for an absent entity; exact designations come right after + // this from the delta's own channel. + if !self.reference_only.is_empty() { + self.reference_only.retain_present(&self.document); + } if !changes.is_empty() { self.invalidate_dependency_index(); } @@ -7555,6 +7609,7 @@ impl Scene { None, None, anno_scale_override.is_some(), + self.reference_only.handles(), ); // Solid B-rep corners snap via the mesh edge list (their wire // path is an empty shell). Enrich before the memo/assembly split @@ -9472,6 +9527,22 @@ impl Scene { .is_some_and(|keep| !keep.contains(&handle))) } + /// True when `handle` addresses a reference-only entity (API v9): + /// plugin-owned display geometry the built-in edit paths treat like a + /// locked-layer entity while its owning plugin keeps write access through + /// the plugin host API. Absent entities are never reference-only. + pub fn is_reference_only(&self, handle: Handle) -> bool { + self.reference_only.is_marked(handle) && self.document.get_entity(handle).is_some() + } + + /// Insert or remove `handle` in the scene-scoped designation set, + /// returning whether membership changed. Pure storage: no epoch bump, no + /// undo bookkeeping — those belong to the callers (the plugin host API + /// mutation and the history channels). + pub(crate) fn set_reference_only_membership(&mut self, handle: Handle, on: bool) -> bool { + self.reference_only.set(handle, on) + } + /// The name of the locked layer `handle` sits on, if any (for messages). pub fn locked_layer_name(&self, handle: Handle) -> Option { let name = self @@ -10763,6 +10834,21 @@ impl Scene { pub fn interaction_candidate_handles( &self, candidates: &crate::scene::pick::interaction_index::InteractionCandidates, + ) -> Option> { + let handles = self.interaction_candidate_handles_all(candidates); + // Reference-only entities (API v9) are plugin-owned display geometry: + // their fills, solids and instanced meshes are not pickable, so the + // click/hover fallback paths that consume this set skip them and a + // click passes through to the editable entity underneath. + handles.map(|mut handles| { + handles.retain(|h| !self.is_reference_only(*h)); + handles + }) + } + + fn interaction_candidate_handles_all( + &self, + candidates: &crate::scene::pick::interaction_index::InteractionCandidates, ) -> Option> { let mut handles: HashSet = candidates .iter() @@ -11008,7 +11094,7 @@ impl Scene { candidate_handles.as_ref(), )); } - handles.retain(|&h| self.passes_selection_filter(h)); + handles.retain(|&h| self.passes_selection_filter(h) && !self.is_reference_only(h)); handles } @@ -11684,6 +11770,9 @@ impl Scene { // `world_offset` subtraction even though `current_layout != "Model"`. // Decide based on the block being tessellated, not the layout. let is_model_block = block_handle == self.model_space_block_handle(); + // The reference-only designation set drives the wire stamp; captured + // as a shared ref like `doc`/`sel` so the rayon workers can read it. + let reference_only_set = self.reference_only.handles(); let bg = if self.current_layout == "Model" { self.bg_color } else { @@ -11800,6 +11889,7 @@ impl Scene { view_aabb, wpp, paper, + reference_only_set, ); (e.common().handle, Arc::new(w)) }) @@ -11846,6 +11936,7 @@ impl Scene { view_aabb, wpp, paper, + reference_only_set, ) }) .collect(); @@ -11880,6 +11971,7 @@ impl Scene { view_aabb, wpp, paper, + reference_only_set, )), Ok(None) => Some(Vec::new()), Err(()) => None, @@ -12900,6 +12992,7 @@ vis_index={:.1} visible_probe={:.1}", None, None, paper, + self.reference_only.handles(), ); // Same solid-corner snaps as the resident paths, so one-off consumers // (overlay changed-wires, edit previews) agree with hit-test/snap. @@ -13220,7 +13313,8 @@ pub(crate) fn drop_layout_viewports(document: &mut codec::CadDocument, block: Ha } /// LAYOUTCREATEVIEWPORT: a layout opened for the first time gets a viewport. -static LAYOUT_CREATE_VIEWPORT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); +static LAYOUT_CREATE_VIEWPORT: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(true); pub fn layout_create_viewport() -> bool { LAYOUT_CREATE_VIEWPORT.load(std::sync::atomic::Ordering::Relaxed) @@ -14507,7 +14601,7 @@ mod delta_undo_tests { let handle = scene.add_entity(EntityType::RasterImage(image)); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects, _parametric_constraints, _named_parameters) = + let (entities, objects, _parametric_constraints, _named_parameters, _designations) = rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, handle); @@ -14532,7 +14626,7 @@ mod delta_undo_tests { scene.erase_entities(&[h1]); let rec = scene.take_undo_recording().unwrap(); assert!(!rec.is_poisoned()); - let (entities, objects, _parametric_constraints, _named_parameters) = + let (entities, objects, _parametric_constraints, _named_parameters, _designations) = rec.into_recorded_images(); assert_eq!(entities.len(), 1); assert_eq!(entities[0].0, h1); diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index d0b7f6917..2c0c7f12f 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -822,6 +822,11 @@ pub struct WireModel { /// (`display_visible` false) it is what object snaps find; drawn, it is /// display only and snaps skip it too ([`Self::is_display_only`]). pub snap_only: bool, + /// Plugin-owned reference-only source (API v9): the entity is display + /// geometry the built-in edit paths must not pick — clicks fall through + /// to the editable entity underneath. Rendering and snapping are + /// unaffected. + pub reference_only: bool, /// Whether this wire is included in plotted output. pub plot_visible: bool, /// `true` when [`fill_tris`] is a real 3-D surface (PolyfaceMesh / @@ -910,6 +915,7 @@ impl WireModel { depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, @@ -1448,6 +1454,7 @@ impl Default for WireModel { depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 58290abeb..ca72e56f7 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -4502,7 +4502,7 @@ mod tests { .take_undo_recording() .expect("an undo recording should still be open"); - let (entities, _objects, _parametric_constraints, _named_parameters) = + let (entities, _objects, _parametric_constraints, _named_parameters, _designations) = recording.into_recorded_images(); let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect(); assert!( @@ -4559,7 +4559,7 @@ mod tests { let recording = scene .take_undo_recording() .expect("an undo recording should still be open"); - let (_entities, _objects, parametric_constraints, _named_parameters) = + let (_entities, _objects, parametric_constraints, _named_parameters, _designations) = recording.into_recorded_images(); assert_eq!( parametric_constraints.len(), diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 50a776218..59d884e73 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -225,7 +225,10 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let mut best_dist = f32::MAX; let mut best: Option<&str> = None; - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { let tolerance = pick_tolerance_px(wire, lw_display, base_radius_px); let mut previous = None; for (index, point) in wire.points.iter().enumerate() { @@ -266,7 +269,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -299,7 +302,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } else { // Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain. for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -354,7 +357,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if let Some(depth) = triangle_ref_hit_depth( @@ -373,7 +376,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.fill_tris.is_empty() { @@ -412,7 +415,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if let Some(depth) = triangle_ref_hit_depth( @@ -431,7 +434,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.pick_tris.is_empty() { @@ -475,7 +478,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let start = glyph.start as usize; @@ -491,7 +494,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.text_verts.is_empty() { @@ -526,7 +529,10 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } let mut hits: Vec<(f32, &str)> = Vec::new(); let mut marker_hits: HashMap<&str, f32> = HashMap::default(); - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { let tolerance = pick_tolerance_px(wire, lw_display, base_radius_px); let mut previous = None; for (index, point) in wire.points.iter().enumerate() { @@ -563,7 +569,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -600,7 +606,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( })); } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -646,7 +652,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if hits.iter().any(|&(_, name)| name == wire.name) { @@ -670,7 +676,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.fill_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { @@ -706,7 +712,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if hits.iter().any(|&(_, name)| name == wire.name) { @@ -730,7 +736,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.pick_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { @@ -762,7 +768,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if hits.iter().any(|&(_, name)| name == wire.name) { @@ -780,7 +786,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.text_verts.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { @@ -1324,7 +1330,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( // path below run identical code. let hit_name = |segment: &SegmentRef| -> Option<&'a str> { let wire = wires.source_wire(segment.wire)?; - if wire.snap_only { + if wire.snap_only || wire.reference_only { return None; } let start = segment.start as usize; @@ -1369,7 +1375,10 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( } } } - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { if !seen.contains(wire.name.as_str()) && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| segment_hits(a, b)) && seen.insert(wire.name.as_str()) @@ -1385,7 +1394,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if already(&wire_hit, triangle.wire) { @@ -1412,7 +1421,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if already(&wire_hit, glyph.wire) { @@ -1435,7 +1444,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( // Degenerate point-only wires have no indexed segment or surface primitive. for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.points.len() >= 2 @@ -1494,7 +1503,7 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let start = segment.start as usize; @@ -1517,7 +1526,10 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>( out.push(wire.name.as_str()); } } - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { if !seen.contains(wire.name.as_str()) && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| cuts(a, b)) && seen.insert(wire.name.as_str()) @@ -1528,7 +1540,7 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>( return out; } for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.points.len() < 2 { @@ -1569,7 +1581,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let start = segment.start as usize; @@ -1596,7 +1608,10 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( out.push(wire.name.as_str()); } } - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { if !seen.contains(wire.name.as_str()) && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| { point_in_polygon(a, poly) @@ -1616,7 +1631,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if seen.contains(wire.name.as_str()) { @@ -1641,7 +1656,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if seen.contains(wire.name.as_str()) { @@ -1662,7 +1677,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( } } for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.points.len() >= 2 @@ -1738,7 +1753,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( let mut out = Vec::new(); let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } // Fallback: when wire has no line geometry (e.g. greek text emits @@ -1826,7 +1841,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let name = wire.name.as_str(); @@ -1939,7 +1954,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( let mut out = Vec::new(); let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } // Same AABB fallback as `box_hit`: when a wire has no line @@ -2029,7 +2044,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let aabb_pts: Vec<[f32; 3]>; diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index badb17851..378b39059 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -588,6 +588,7 @@ mod tests { let h = doc.add_entity(donut).unwrap(); let entity = doc.get_entity(h).unwrap(); let selected = rustc_hash::FxHashSet::default(); + let reference_only = rustc_hash::FxHashSet::default(); let wires = crate::scene::convert::tess::tessellate_entity( &doc, &selected, @@ -600,6 +601,7 @@ mod tests { None, None, false, + &reference_only, ); assert_eq!( wires.len(), diff --git a/src/scene/reference_only.rs b/src/scene/reference_only.rs new file mode 100644 index 000000000..24a82d098 --- /dev/null +++ b/src/scene/reference_only.rs @@ -0,0 +1,187 @@ +//! Reference-only designation (API v9). +//! +//! A reference-only entity is plugin-owned display geometry: it renders and +//! snaps normally, but the built-in edit paths treat it like an entity on a +//! locked layer — no grips, no click picking (a click passes through to the +//! editable entity underneath), and user-driven writes refuse it. The owning +//! plugin keeps full write access through the plugin host API, so it can +//! refresh derived geometry in place. +//! +//! Storage is a scene-scoped in-memory set of entity handles — the host +//! writes nothing of its own into the saved DWG/DXF: no APPID registration, +//! no XDATA marker on the entity, no host-owned state a saved file depends +//! on. The set lives and dies with the scene (per drawing tab): closing a +//! tab forgets its designations, and a freshly opened drawing starts +//! undesignated. The owning plugin re-marks its entities when the host +//! dispatches the document-opened notification (API v9's +//! `BuiltinPlugin::on_notification` surface), which makes the in-memory +//! designation fully equivalent to a persisted one for its use case. +//! +//! Undo keeps the entity-write contract: a designation change performed +//! inside a plugin-opened `push_undo` scope joins that scope, a scope-less +//! change is one undo step of its own, and undo/redo restores the +//! designation with the entities it belongs to. Because the set is not part +//! of the document, set mutations are recorded as explicit first-touch +//! before-images on the history delta (see +//! `DeltaSnapshot::reference_designations`), the same channel pattern +//! `parametric_constraints` uses. + +use codec::types::Handle; +use rustc_hash::FxHashSet; + +/// The in-memory designation set owned by a [`Scene`](crate::Scene). +/// +/// Deliberately tiny: a plain set of handles plus the operations the scene, +/// the plugin host and the history channels use. Not `Serialize` — the set +/// is never written anywhere and never leaves the process. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct ReferenceOnlyDesignation { + handles: FxHashSet, +} + +impl ReferenceOnlyDesignation { + /// Raw membership (by handle) — the caller guarantees the entity exists, + /// or accepts a stale entry; [`Scene::is_reference_only`] does the + /// existential check. + pub(crate) fn is_marked(&self, handle: Handle) -> bool { + self.handles.contains(&handle) + } + + /// Insert or remove `handle`, returning whether the membership changed. + /// Idempotent: re-marking a designated handle reports `false` and touches + /// nothing, which is what terminates the re-mark loop driven by the + /// document-changed notification dispatch. + pub(crate) fn set(&mut self, handle: Handle, on: bool) -> bool { + if on { + self.handles.insert(handle) + } else { + self.handles.remove(&handle) + } + } + + /// Forget `handle` — called when its entity is erased so the set never + /// leaks an entry for a removed entity. The undo channels restore the + /// entry together with the entity on undo. + pub(crate) fn remove(&mut self, handle: Handle) { + self.handles.remove(&handle); + } + + /// The marked handles, for the tessellation pipelines (wire stamping) + /// and the history channels. + pub(crate) fn handles(&self) -> &FxHashSet { + &self.handles + } + + /// True when nothing is designated. + pub(crate) fn is_empty(&self) -> bool { + self.handles.is_empty() + } + + /// Drop entries whose entities no longer exist. The undo/redo channels + /// model the exact designation state, so this is only a defensive sweep + /// after history replay (an entity the chosen delta side removed must + /// never stay designated through a stale entry). + pub(crate) fn retain_present(&mut self, document: &codec::CadDocument) { + self.handles + .retain(|handle| document.get_entity(*handle).is_some()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codec::entities::{EntityType, Line}; + + fn handle(value: u64) -> Handle { + Handle::new(value) + } + + #[test] + fn marking_and_unmarking_report_and_track_membership() { + let mut designation = ReferenceOnlyDesignation::default(); + assert!(!designation.is_marked(handle(7))); + assert!(designation.set(handle(7), true)); + assert!(designation.is_marked(handle(7))); + // Idempotent: a second mark changes nothing and reports it — the + // property that terminates re-mark loops. + assert!(!designation.set(handle(7), true)); + assert!(designation.set(handle(7), false)); + assert!(!designation.is_marked(handle(7))); + assert!(!designation.set(handle(7), false)); + assert!(designation.handles().is_empty()); + } + + #[test] + fn removing_a_handle_forgets_it() { + let mut designation = ReferenceOnlyDesignation::default(); + designation.set(handle(7), true); + designation.remove(handle(7)); + assert!(!designation.is_marked(handle(7))); + // Removing an unmarked handle is a no-op. + designation.remove(handle(8)); + assert!(designation.is_empty()); + } + + #[test] + fn retain_present_drops_entries_of_absent_entities() { + let mut document = codec::CadDocument::new(); + let live = document.add_entity(EntityType::Line(Line::new())).unwrap(); + let mut designation = ReferenceOnlyDesignation::default(); + designation.set(live, true); + designation.set(handle(999_999), true); + designation.retain_present(&document); + assert!(designation.is_marked(live)); + assert!(!designation.is_marked(handle(999_999))); + } + + #[test] + fn scene_membership_is_guarded_by_entity_presence() { + let mut scene = crate::scene::Scene::new(); + let handle = scene + .document + .add_entity(EntityType::Line(Line::new())) + .unwrap(); + assert!(!scene.is_reference_only(handle)); + assert!(scene.set_reference_only_membership(handle, true)); + assert!(scene.is_reference_only(handle)); + // A stale set entry alone never designates: absent entities are not + // reference-only even if the set still lists them. + scene.set_reference_only_membership(Handle::new(999_999), true); + assert!(!scene.is_reference_only(Handle::new(999_999))); + } + + #[test] + fn tessellated_wires_stamp_from_the_designation_set() { + let document = codec::CadDocument::new(); + let line = EntityType::Line(Line::new()); + let handle = line.common().handle; + let selected: rustc_hash::FxHashSet = Default::default(); + let none: rustc_hash::FxHashSet = Default::default(); + let mut designated: rustc_hash::FxHashSet = Default::default(); + designated.insert(handle); + let context = crate::scene::cache::block_cache::BlockCache::default(); + for designated in [&none, &designated] { + let wires = crate::scene::tessellate_entity( + &document, + &selected, + None, + [0.0, 0.0, 0.0, 1.0], + 1.0, + None, + &line, + Some(&context), + None, + None, + false, + designated, + ); + assert!(!wires.is_empty()); + assert!( + wires + .iter() + .all(|wire| wire.reference_only == designated.contains(&handle)), + "every wire carries the set's stamp for its entity" + ); + } + } +} diff --git a/src/scene/reference_only_tests.rs b/src/scene/reference_only_tests.rs new file mode 100644 index 000000000..5127d6144 --- /dev/null +++ b/src/scene/reference_only_tests.rs @@ -0,0 +1,109 @@ +//! Pick-side tests for the reference-only designation (API v9): clicks pass +//! through reference-only wires and box selections skip them. + +use crate::scene::model::wire_model::WireModel; +use crate::scene::pick::hit_test; +use crate::scene::Scene; +use codec::Handle; +use glam::{DVec3, Mat4}; +use iced::{Point, Rectangle, Size}; + +/// A view whose world coordinates map one-to-one onto screen coordinates +/// (the convention of `block_base_point_tests::WorldAsScreen`). +struct WorldAsScreen { + view_rot: Mat4, + eye: DVec3, + bounds: Rectangle, +} + +impl WorldAsScreen { + fn new(extent: f32) -> Self { + Self { + view_rot: Mat4::from_scale(glam::Vec3::new(2.0 / extent, -2.0 / extent, 1.0)), + eye: DVec3::new(extent as f64 / 2.0, extent as f64 / 2.0, 0.0), + bounds: Rectangle::new(Point::ORIGIN, Size::new(extent, extent)), + } + } + + fn at(&self, point: [f64; 3]) -> Point { + Point::new(point[0] as f32, point[1] as f32) + } +} + +fn segment_wire(name: &str, from: [f64; 3], to: [f64; 3], reference_only: bool) -> WireModel { + WireModel { + name: name.to_owned(), + points: vec![ + [from[0] as f32, from[1] as f32, from[2] as f32], + [to[0] as f32, to[1] as f32, to[2] as f32], + ], + reference_only, + ..WireModel::default() + } +} + +fn click_at(point: [f64; 3], view: &WorldAsScreen, wires: &[WireModel]) -> Option { + hit_test::click_hit( + view.at(point), + wires, + view.view_rot, + view.eye, + view.bounds, + false, + 8.0, + &rustc_hash::FxHashMap::default(), + ) + .and_then(Scene::handle_from_wire_name) +} + +#[test] +fn clicks_pass_through_reference_only_wires() { + let view = WorldAsScreen::new(100.0); + let editable = segment_wire("1", [25.0, 25.0, 0.0], [75.0, 25.0, 0.0], false); + let reference = segment_wire("2", [25.0, 30.0, 0.0], [75.0, 30.0, 0.0], true); + let wires = [editable, reference]; + + // A click sitting exactly on the reference-only wire passes through to + // the editable wire 5 px away (inside the 8 px aperture). + assert_eq!( + click_at([50.0, 30.0, 0.0], &view, &wires), + Some(Handle::new(1)), + "a click on reference-only geometry must resolve to the entity underneath" + ); + assert_eq!( + click_at([50.0, 25.0, 0.0], &view, &wires), + Some(Handle::new(1)) + ); + + // With only the reference-only wire under the cursor the click misses. + let reference_only = [wires[1].clone()]; + assert_eq!(click_at([50.0, 30.0, 0.0], &view, &reference_only), None); + assert_eq!(click_at([50.0, 25.0, 0.0], &view, &reference_only), None); +} + +#[test] +fn box_selections_skip_reference_only_wires() { + let view = WorldAsScreen::new(100.0); + let editable = segment_wire("1", [25.0, 25.0, 0.0], [75.0, 25.0, 0.0], false); + let reference = segment_wire("2", [25.0, 30.0, 0.0], [75.0, 30.0, 0.0], true); + let wires = [editable, reference]; + + let hits = hit_test::box_hit( + view.at([10.0, 10.0, 0.0]), + view.at([90.0, 50.0, 0.0]), + false, + &wires, + view.view_rot, + view.eye, + view.bounds, + ); + let handles: Vec = hits + .into_iter() + .filter_map(Scene::handle_from_wire_name) + .collect(); + assert_eq!( + handles, + vec![Handle::new(1)], + "a box selection must not pick reference-only geometry" + ); +} diff --git a/src/scene/selection.rs b/src/scene/selection.rs index f4671b6dc..44e896994 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -204,6 +204,7 @@ impl Scene { let selected = handles .into_iter() .filter(|handle| self.passes_selection_filter(*handle)) + .filter(|handle| !self.is_reference_only(*handle)) .filter(|handle| { self.document.get_entity(*handle).is_some_and(|entity| { self.resident_entity_visible( @@ -1060,7 +1061,15 @@ impl Scene { // ── Erase ───────────────────────────────────────────────────────────── pub fn erase_entities(&mut self, handles: &[Handle]) { - self.remove_entities(handles, true); + self.remove_entities(handles, true, true); + } + + /// Like [`erase_entities`](Self::erase_entities) but without the + /// reference-only skip. The plugin owns reference-only entities and + /// removes them through the plugin host API; locked-layer policy stays + /// enforced either way. + pub fn erase_entities_plugin(&mut self, handles: &[Handle]) { + self.remove_entities(handles, true, false); } /// Roll back entities created by the current command after a downstream @@ -1069,10 +1078,15 @@ impl Scene { /// The shared removal path keeps first-touch undo state, history objects, /// groups, selection, and derived caches coherent. pub(crate) fn rollback_new_entities(&mut self, handles: &[Handle]) { - self.remove_entities(handles, false); + self.remove_entities(handles, false, false); } - fn remove_entities(&mut self, handles: &[Handle], respect_layer_locks: bool) { + fn remove_entities( + &mut self, + handles: &[Handle], + respect_layer_locks: bool, + respect_reference_only: bool, + ) { let erase_handles = self.handles_expanded_for_leader_annotations(handles); // One pass over document.objects instead of a scan per erased handle. @@ -1096,10 +1110,15 @@ impl Scene { let mut hover_changed = false; for &h in &erase_handles { - // Objects on a locked layer can't be erased. + // Objects on a locked layer can't be erased. Reference-only + // entities are plugin-owned: only the plugin-owned erase path + // may remove them. if respect_layer_locks && self.is_layer_locked(h) { continue; } + if respect_reference_only && self.is_reference_only(h) { + continue; + } let settings_handle = self.document.get_entity(h).and_then(|entity| match entity { EntityType::Extended(extended) => match &extended.data { codec::entities::ExtendedEntityData::SectionObject(data) @@ -1125,6 +1144,12 @@ impl Scene { let before = self.document.objects.get(&manager_handle).cloned(); self.record_undo_object_before(manager_handle, before); } + // The designation set is scene state, not document state: a + // designated entity's set entry joins the same first-touch + // recording so undo restores entity and designation together. + if self.reference_only.is_marked(h) { + self.record_undo_reference_designation_before(h); + } } self.delete_solid_history(h); if let Some(settings_handle) = settings_handle { @@ -1143,6 +1168,9 @@ impl Scene { } self.remember_removed_cache_categories(h); self.document.remove_entity_arc(h); + // The set entry dies with its entity (an absent entity is never + // reference-only). Undo restores it through the recorded channel. + self.reference_only.remove(h); selection_changed |= self.selected.remove(&h); self.selected_order.retain(|selected| *selected != h); if self.hover_highlight == Some(h) { diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index e285497f4..3d068acae 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -382,6 +382,7 @@ pub fn apply_along( depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 7a8f9b99e..81a559e9f 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -4177,6 +4177,7 @@ impl Scene { None, None, content_viewport, + self.reference_only.handles(), ); for wire in &mut context_wires { wire.color = tint;