Merge pull request #1747 from schoeller/reference_only_entities

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

Resolves the new DeltaSnapshot site in block_editor.rs.

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

No files matched your search

+1 -1
View File
@@ -1263,7 +1263,7 @@ fn generate_version_info(out_dir: &Path) {
"acadrust_version": opencadcodec.version.to_string(), "acadrust_version": opencadcodec.version.to_string(),
"acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()), "acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()),
"rustc_version": rustc_version, "rustc_version": rustc_version,
"api_version": 8, "api_version": 9,
"api_version_min_supported": 3, "api_version_min_supported": 3,
"build_timestamp": build_timestamp, "build_timestamp": build_timestamp,
}); });
+55
View File
@@ -396,6 +396,15 @@ pub enum HostNotification {
tab_id: u64, tab_id: u64,
epoch: 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. /// Fallback for notification variants added in future minor revisions.
/// Carries the raw bincode payload so an older peer can ignore it without /// Carries the raw bincode payload so an older peer can ignore it without
/// failing deserialization. /// 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, tab_id).map_err(serde::ser::Error::custom)?;
bincode::serialize_into(&mut bytes, epoch).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), HostNotification::Unknown(raw) => bytes.extend_from_slice(raw),
} }
bytes.serialize(serializer) bytes.serialize(serializer)
@@ -490,6 +503,9 @@ impl<'de> Deserialize<'de> for HostNotification {
9 => bincode::deserialize(rest) 9 => bincode::deserialize(rest)
.map(|(tab_id, epoch)| HostNotification::DrawingChanged { tab_id, epoch }) .map(|(tab_id, epoch)| HostNotification::DrawingChanged { tab_id, epoch })
.map_err(serde::de::Error::custom), .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)), _ => Ok(HostNotification::Unknown(bytes)),
} }
} }
@@ -1175,6 +1191,45 @@ pub trait HostApi {
fn list_xrecords(&self, _dictionary_path: &[&str]) -> Result<Vec<String>, String> { fn list_xrecords(&self, _dictionary_path: &[&str]) -> Result<Vec<String>, String> {
Err("XRecord listing is not supported by this host".to_owned()) 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. /// Configuration properties for creating or modifying a layer.
+17
View File
@@ -692,6 +692,23 @@ impl HostApi for PluginHostApi {
Err(error) => Err(error.to_string()), 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. /// Sentinel reader used when the shared-memory view could not be initialized.
@@ -226,6 +226,11 @@ pub enum PluginRequest {
ListXRecords { ListXRecords {
dictionary_path: Vec<String>, dictionary_path: Vec<String>,
}, },
/// V9 (additive): mark an entity reference-only (plugin-owned display data).
SetReferenceOnly {
handle: Handle,
reference_only: bool,
},
} }
/// Responses the host sends back for `PluginRequest`. /// Responses the host sends back for `PluginRequest`.
@@ -272,6 +277,8 @@ pub enum PluginResponse {
XRecordDeleteResult(Result<crate::host::XRecord, String>), XRecordDeleteResult(Result<crate::host::XRecord, String>),
/// V8 (additive): XRecord names under a dictionary path. /// V8 (additive): XRecord names under a dictionary path.
XRecordNames(Result<Vec<String>, String>), XRecordNames(Result<Vec<String>, String>),
/// V9 (additive): outcome of the reference-only designation.
SetReferenceOnlyResult(Result<(), String>),
} }
/// Messages sent from the host to the plugin runner. /// Messages sent from the host to the plugin runner.
+6
View File
@@ -151,5 +151,11 @@ pub fn handle_plugin_request(
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect(); let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
PluginResponse::XRecordNames(host.list_xrecords(&path)) PluginResponse::XRecordNames(host.list_xrecords(&path))
} }
SetReferenceOnly {
handle,
reference_only,
} => {
PluginResponse::SetReferenceOnlyResult(host.set_reference_only(handle, reference_only))
}
} }
} }
@@ -987,6 +987,23 @@ impl HostApi for V4PluginHostApi {
Err(error) => Err(error.to_string()), 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)] #[cfg(test)]
@@ -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::<PluginRequest>(&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::<PluginRequest>(&bytes).unwrap(),
PluginRequest::SetReferenceOnly {
reference_only: false,
..
}
));
let bytes = bincode::serialize(&PluginResponse::SetReferenceOnlyResult(Ok(()))).unwrap();
assert!(matches!(
bincode::deserialize::<PluginResponse>(&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::<PluginResponse>(&bytes).unwrap(),
PluginResponse::SetReferenceOnlyResult(Err(error)) if error == "entity is on a locked layer")
);
}
#[test] #[test]
fn unknown_notification_deserializes_as_unknown() { fn unknown_notification_deserializes_as_unknown() {
// Build a payload with an unknown discriminant (99) followed by some // Build a payload with an unknown discriminant (99) followed by some
+5 -2
View File
@@ -24,8 +24,11 @@
/// undoable replacement of a set of existing entities. v8 appends name-addressed /// undoable replacement of a set of existing entities. v8 appends name-addressed
/// XRecord object CRUD under named-object-dictionary paths /// XRecord object CRUD under named-object-dictionary paths
/// (`HostApi::create_xrecord` / `read_xrecord` / `update_xrecord` / /// (`HostApi::create_xrecord` / `read_xrecord` / `update_xrecord` /
/// `delete_xrecord` / `list_xrecords`). /// `delete_xrecord` / `list_xrecords`). v9 appends the reference-only entity
pub const API_VERSION: u32 = 8; /// 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 /// 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 /// compiled cdylibs usable as long as their vtable layout is a prefix of the
+94
View File
@@ -362,3 +362,97 @@ in one scope = one step, undone and redone whole),
`xrecords_survive_dxf_and_dwg_round_trips` (typed entries reload from both writers), the IPC round-trips `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 `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. host-level and stands on its own; a Python mapping (`doc.xrecords`-style) is future work.
**Reference-only entities (2026-10-13, API v9).** The host gained a per-entity "reference-only"
designation for plugin-owned display geometry: `HostApi::set_reference_only` marks (or unmarks) an
entity as display data the person at the screen must not edit, while the owning plugin keeps full
write access. Behaviour split: a reference-only entity renders and snaps normally but is not
grip-editable (`refresh_selected_grips` yields nothing for it and an active grip edit on a newly
designated target is cancelled like a locked-layer one), not click-pickable (the pick-side wire
flag skips it, so a click passes through to the editable entity underneath), and skipped by box,
window, lasso, fence and select-all selection gathers. Built-in user write paths treat it like a
locked-layer entity — `Scene::update_entity` / `update_entities`, `erase_entities`, MCP
`set_properties` and modify machinery all refuse or skip it — while the plugin-owned variants
(`Scene::update_entity_plugin` / `erase_entities_plugin`, mirroring the
`remove_entities(handles, respect_layer_locks)` editor split) and the whole `HostSession` surface
(`add_entity`, `update_entity`, `update_entities_transaction`, `remove_entity`, XDATA records,
layer/block table operations) write through, so a preview refresh never needs unlock/relock cycles.
Locks still apply to the plugin: marking and clearing refuse absent entities and locked layers,
and every refusal is decided before the undo scope is touched. Undo is the entity-write contract:
a change inside a plugin-opened `push_undo` scope joins it (entity writes plus designations are one
step under the plugin's label); a scope-less change is one undo step of its own; an idempotent
re-designation records nothing. Storage: a human-readable marker value in an XDATA record under the
host-owned `OCS` APPID (registered on first use, one record per application per entity, foreign
values preserved verbatim; stale verbatim DWG EED is dropped on rewrite), so the flag round-trips
DWG and DXF save/reload with the entity and cannot collide with plugin APPID namespaces — chosen
over a codec common-data bit because the codec is a pinned external dependency. The MCP `entities`
read exposes the flag as a top-level `reference_only` boolean on every entity record. Known limit:
entities nested inside block instances (the block cache rebuilds instance wires without the flag) —
reference-only targets top-level geometry. Evidence: the storage unit tests
`marking_flags_and_unmarking_clears`, `foreign_values_under_the_host_app_id_survive`,
`other_apps_are_untouched`; the host behaviour tests `reference_only_refusals_record_no_undo_step`,
`reference_only_designation_composes_undo_like_entity_writes` (own step, scope join, whole-scope
undo), `reference_only_survives_dxf_and_dwg_round_trips` (both writers, APPID table included),
`user_paths_refuse_and_plugin_paths_write_reference_only_entities` (grips suppressed, user
update/erase refused, plugin update/erase through, designation survives a refresh-style replace),
`select_all_visible_skips_reference_only_entities`; the pick tests `clicks_pass_through_reference_only_wires`
and `box_selections_skip_reference_only_wires`; the record surface test
`entities_records_expose_the_reference_only_designation`; and the IPC round-trip
`reference_only_request_and_response_roundtrip`. Like the XRecord API, a Python mapping is future
work.
**Reference-only rework: scene-scoped storage and notification dispatch (2026-10-14, API v9).**
Supersedes the storage decision above after the upstream review: the designation is no longer
written to the file at all. The XDATA marker under the host-owned `OCS` APPID (and the APPID
registration, the verbatim-EED rewrite handling and the storage unit tests built on it) is removed;
the single source of truth is now a scene-scoped in-memory set of entity handles
(`Scene::reference_only`, `scene::reference_only`), deliberately not serialized — the host writes
nothing of its own into the saved DWG/DXF, the set dies with the drawing tab, and a reopened file
starts undesignated. Every behaviour site is unchanged and driven by the set: the wire
`tessellate_entity` stamp (threaded through the tessellation pipelines as a plain handle-set
parameter), the pick-side wire filters, the selection-gather retains, the update/erase refusals
and the MCP `entities` boolean. Undo keeps the entity-write contract through an explicit
designation channel on each history delta (the `parametric_constraints` pattern): a marking inside
a plugin-opened `push_undo` scope joins it, a scope-less change is one step of its own, an
idempotent re-mark records nothing, erasing a designated entity drops the set entry in the same
step, and undo/redo restores entity and designation together. The round-trip test is inverted:
`reference_only_designation_is_not_written_to_dwg_or_dxf` now asserts the marker appears in
neither format's bytes, that no `OCS` APPID or marker record survives a reload, and that the
designation stays live in memory.
The same rework closes the missing load callback with real notification dispatch: the host now
delivers the existing `HostNotification`s to every loaded native plugin through
`BuiltinPlugin::on_notification` — `DocumentChanged` (the active tab's settled geometry epoch)
and `SelectionChanged` (the plain selection set) at the fingerprint-guarded message boundaries
the v4 bridge already uses (`notify_plugins_selection_changed` / `notify_plugins_document_changed`,
plus the automation post-op boundary), and a new additive `DocumentOpened { tab_id }` variant
(discriminant 10, the counterpart of the existing `DocumentTabClosed`) that fires once per whole
document install — file open, template new, system open — keyed on a new per-tab document
generation. Boundary rule: dispatch happens after the mutation settles and pending history scopes
closed, never mid-undo/mid-pick, and history (undo/redo) replay itself emits nothing — the
boundary is the only channel; plugin-initiated writes publish immediately after settling, like
the drawing-changed event, with the boundary fingerprint deduplicating. Handlers may call HostApi
write methods through the same session (`set_reference_only` at minimum), and the loop closes
because an idempotent re-mark bumps nothing: no epoch, no undo step, no publish. With
`DocumentOpened`, a plugin re-marks on every load and the in-memory designation is fully
equivalent to a persisted one for its use case; the remaining gap (a plugin process restarted
while its drawing is open) stays open and documented — the plugin's own session persistence
covers it on reopen. No Python mapping, like the rest of this increment. Evidence: the set-storage
tests `marking_and_unmarking_report_and_track_membership`, `removing_a_handle_forgets_it`,
`retain_present_drops_entries_of_absent_entities`, `scene_membership_is_guarded_by_entity_presence`
and `tessellated_wires_stamp_from_the_designation_set`; the host tests
`reference_only_refusals_record_no_undo_step`,
`reference_only_designation_composes_undo_like_entity_writes`,
`reference_only_designation_is_not_written_to_dwg_or_dxf`,
`erasing_a_designated_entity_undoes_designation_with_it`,
`user_paths_refuse_and_plugin_paths_write_reference_only_entities`,
`select_all_visible_skips_reference_only_entities`,
`entities_records_expose_the_reference_only_designation`; the unchanged pick tests; the dispatch
tests `an_edit_dispatches_document_changed_once_per_epoch`,
`a_selection_change_dispatches_selection_changed`,
`a_document_open_dispatches_document_opened_once`,
`a_file_open_dispatches_document_opened_through_the_message_flow`,
`idempotent_designation_remarks_cannot_loop_the_dispatch`,
`history_replay_emits_no_plugin_notifications_itself` and
`dispatching_without_loaded_plugins_leaves_the_app_state_untouched`; and the IPC round-trip
`reference_only_request_and_response_roundtrip` (unchanged).
+112 -1
View File
@@ -22,7 +22,7 @@ without it keeps working, and the default `HostApi` methods refuse.
| Area | `HostApi` entry | What it does | | Area | `HostApi` entry | What it does |
|---|---|---| |---|---|---|
| Read | `document()`, `entity_snapshot` | The full `CadDocument` snapshot; a detached JSON view of any typed entity. | | 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). | | 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. | | 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). | | 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 dictionary's own order, so audits need no prior knowledge. Not yet mapped to
Python. 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 ## Commands
`run_command` takes a `CommandRequest` (`Run`, `Start`, `Point`, `Text`, `Token`, `run_command` takes a `CommandRequest` (`Run`, `Start`, `Point`, `Text`, `Token`,
+295
View File
@@ -690,6 +690,10 @@ impl OpenCADStudio {
self.notify_plugins_selection_changed(); self.notify_plugins_selection_changed();
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_document_changed(); 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 res
} }
@@ -725,6 +729,9 @@ impl OpenCADStudio {
// The headless session starts on the welcome (Start) tab, which // The headless session starts on the welcome (Start) tab, which
// blocks drawing commands; turn it into a real drawing. // blocks drawing commands; turn it into a real drawing.
self.tabs[i].is_start = false; 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() self.entity_summary()
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -763,6 +770,10 @@ impl OpenCADStudio {
self.tabs[i].current_path = Some(path_buf); self.tabs[i].current_path = Some(path_buf);
self.tabs[i].is_start = false; self.tabs[i].is_start = false;
self.tabs[i].scene.rebuild_derived_caches(); 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(); let mut summary = self.entity_summary();
if dropped > 0 { if dropped > 0 {
if let Some(obj) = summary.as_object_mut() { if let Some(obj) = summary.as_object_mut() {
@@ -1739,6 +1750,290 @@ mod tests {
app.notify_plugins_document_changed(); app.notify_plugins_document_changed();
assert_eq!(app.last_plugin_document, Some(published)); assert_eq!(app.last_plugin_document, Some(published));
} }
// ── Native-plugin notification dispatch (API v9) ─────────────────────
//
// The app-level tests observe the dispatch seam in
// `plugin::v4_support`: one recorded entry stands for one broadcast to
// every loaded native plugin, so counts here are counts per plugin. The
// write-semantics rule under test: dispatch happens at the message
// boundary — after the mutation settled, never mid-undo/mid-pick — so
// handlers may write through the plugin host API, and the fingerprint
// guards plus idempotent re-marks keep that loop finite.
fn plugin_dispatch_log() -> Vec<String> {
crate::plugin::v4_support::take_test_dispatch_log()
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn an_edit_dispatches_document_changed_once_per_epoch() {
use codec::entities::Point;
use codec::EntityType;
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let _ = plugin_dispatch_log();
let tab = app.active_tab;
let mut host = super::super::plugin_host::HostSession::new(&mut app, tab);
host.add_entity(EntityType::Point(Point::new()));
drop(host);
app.notify_plugins_document_changed();
let epoch = app.tabs[tab].scene.geometry_epoch;
let log = plugin_dispatch_log();
let changed: Vec<_> = log
.iter()
.filter(|entry| *entry == &format!("DocumentChanged({epoch})"))
.collect();
assert_eq!(
changed.len(),
1,
"one settled edit dispatches exactly one DocumentChanged per plugin: {log:?}"
);
// The same epoch at the next boundary dispatches nothing further.
app.notify_plugins_document_changed();
let log = plugin_dispatch_log();
assert!(
log.iter()
.all(|entry| *entry != format!("DocumentChanged({epoch})")),
"the fingerprint guard suppresses duplicate dispatches: {log:?}"
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_selection_change_dispatches_selection_changed() {
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#);
let _ = plugin_dispatch_log();
let i = app.active_tab;
let handle = app.tabs[i]
.scene
.document
.entities()
.next()
.expect("the drawn line")
.common()
.handle;
let selection: rustc_hash::FxHashSet<codec::Handle> = std::iter::once(handle).collect();
app.tabs[i].scene.replace_selection(selection);
app.notify_plugins_selection_changed();
let log = plugin_dispatch_log();
assert!(
log.contains(&"SelectionChanged(1 handles)".to_string()),
"a real selection change dispatches SelectionChanged: {log:?}"
);
// Unchanged selection at the next boundary: nothing further.
app.notify_plugins_selection_changed();
let log = plugin_dispatch_log();
assert_eq!(log.len(), 0, "no duplicate dispatch: {log:?}");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_document_open_dispatches_document_opened_once() {
let mut app = super::OpenCADStudio::new_for_test();
let _ = plugin_dispatch_log();
app.automation_op(r#"{"op":"new"}"#);
let tab_id = app.tabs[app.active_tab].id;
let log = plugin_dispatch_log();
let opened: Vec<_> = log
.iter()
.filter(|entry| **entry == format!("DocumentOpened({tab_id})"))
.collect();
assert_eq!(
opened.len(),
1,
"a new document announces exactly one open: {log:?}"
);
// A follow-up op does not repeat it: generation-keyed dispatch.
app.automation_op(r#"{"op":"query","type":"Line"}"#);
let log = plugin_dispatch_log();
assert!(
log.iter()
.all(|entry| *entry != format!("DocumentOpened({tab_id})")),
"no duplicate open dispatch: {log:?}"
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_file_open_dispatches_document_opened_through_the_message_flow() {
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let path =
std::env::temp_dir().join(format!("ocs_opened_dispatch_{}.dxf", std::process::id(),));
crate::io::save(&app.tabs[app.active_tab].scene.document, &path)
.expect("save the open fixture");
app.opening = Some(crate::app::OpenProgress {
id: 7,
name: "ocs_opened_dispatch.dxf".to_string(),
source_path: Some(path.clone()),
size_bytes: 0,
state: std::sync::Arc::new(crate::io::OpenProgressState::new(
crate::app::OPEN_PHASE_CACHING,
)),
started: std::time::Instant::now(),
recovery_error: None,
recovery_read_stats: None,
fingerprint: None,
});
let bytes = std::fs::read(&path).expect("read the fixture back");
let (document, _dropped) =
crate::io::load_bytes_finalized(&path, bytes).expect("reparse the fixture");
let caches = crate::scene::build_derived_caches_with_progress(&document, &|_| {}, None);
let _ = plugin_dispatch_log();
let _ = app.update(crate::app::Message::FileOpened(
7,
Ok((
"ocs_opened_dispatch.dxf".to_string(),
path.clone(),
document,
caches,
)),
));
let tab_id = app.tabs[app.active_tab].id;
let log = plugin_dispatch_log();
let opened: Vec<_> = log
.iter()
.filter(|entry| **entry == format!("DocumentOpened({tab_id})"))
.collect();
assert_eq!(
opened.len(),
1,
"the file-open message announces exactly one open: {log:?}"
);
// The next message boundary does not repeat it.
app.notify_plugins_document_opened();
let log = plugin_dispatch_log();
assert_eq!(log.len(), 0, "generation-keyed dispatch dedupes: {log:?}");
let _ = std::fs::remove_file(&path);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn idempotent_designation_remarks_cannot_loop_the_dispatch() {
use codec::entities::Point;
use codec::EntityType;
// Models the consumer plugin's re-mark handler: on DocumentChanged it
// re-marks its entities through the plugin host API. The write goes
// through, but because a re-mark of already-designated geometry is a
// documented no-op — no epoch bump, no undo step, no publish — the
// notification loop terminates instead of feeding itself.
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let _ = plugin_dispatch_log();
let tab = app.active_tab;
let mut host = super::super::plugin_host::HostSession::new(&mut app, tab);
let handle = host.add_entity(EntityType::Point(Point::new()));
host.set_reference_only(handle, true).expect("mark");
// The handler's "write": an idempotent re-mark after the notification.
host.set_reference_only(handle, true)
.expect("idempotent re-mark");
drop(host);
assert!(app.tabs[tab].scene.is_reference_only(handle));
app.notify_plugins_document_changed();
let epoch = app.tabs[tab].scene.geometry_epoch;
let log = plugin_dispatch_log();
let changed: Vec<_> = log
.iter()
.filter(|entry| **entry == format!("DocumentChanged({epoch})"))
.collect();
assert_eq!(
changed.len(),
1,
"the designation write dispatched once, not one per re-mark: {log:?}"
);
// A further boundary run finds the same epoch: the loop has ended.
app.notify_plugins_document_changed();
let log = plugin_dispatch_log();
assert_eq!(log.len(), 0, "idempotent re-marks emit nothing: {log:?}");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn history_replay_emits_no_plugin_notifications_itself() {
use codec::entities::Point;
use codec::EntityType;
// Undo/redo replay never publishes: the notification boundary is the
// only channel, so replaying history steps in isolation is silent.
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let _ = plugin_dispatch_log();
let tab = app.active_tab;
let mut host = super::super::plugin_host::HostSession::new(&mut app, tab);
let handle = host.add_entity(EntityType::Point(Point::new()));
host.set_reference_only(handle, true).expect("mark");
drop(host);
let _ = plugin_dispatch_log();
app.undo_steps(1);
assert!(
!app.tabs[tab].scene.is_reference_only(handle),
"undo reverted the designation"
);
let log = plugin_dispatch_log();
assert_eq!(
log.len(),
0,
"history replay itself emits no plugin notifications: {log:?}"
);
app.redo_steps(1);
assert!(app.tabs[tab].scene.is_reference_only(handle));
let log = plugin_dispatch_log();
assert_eq!(log.len(), 0, "redo replay is equally silent: {log:?}");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dispatching_without_loaded_plugins_leaves_the_app_state_untouched() {
// The default `on_notification` is a no-op; with no plugin processes
// loaded, a full boundary pass must not disturb the host.
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let epoch = app.tabs[i].scene.geometry_epoch;
let selected: Vec<codec::Handle> = app.tabs[i].scene.selected.iter().copied().collect();
let _ = plugin_dispatch_log();
app.notify_plugins_selection_changed();
app.notify_plugins_document_changed();
app.notify_plugins_document_opened();
let log = plugin_dispatch_log();
assert_eq!(app.tabs[i].scene.geometry_epoch, epoch);
assert_eq!(
app.tabs[i]
.scene
.selected
.iter()
.copied()
.collect::<Vec<_>>(),
selected
);
assert!(
log.iter().all(|entry| !entry.starts_with("DocumentOpened")),
"a start-tab-free app does not announce spurious opens: {log:?}"
);
}
use crate::app::OpenCADStudio; use crate::app::OpenCADStudio;
use serde_json::{json, Value}; use serde_json::{json, Value};
+1
View File
@@ -223,6 +223,7 @@ impl OpenCADStudio {
active_layer, active_layer,
structure, structure,
parametric_constraints, parametric_constraints,
reference_designations: Vec::new(),
named_parameters, named_parameters,
label: format!("Edit block {}", session.block_name), label: format!("Edit block {}", session.block_name),
}) })
+3
View File
@@ -466,6 +466,9 @@ impl OpenCADStudio {
self.tabs[i].current_path = None; self.tabs[i].current_path = None;
self.tabs[i].is_start = false; self.tabs[i].is_start = false;
self.tabs[i].dirty = true; 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) Ok(purged)
} }
+51
View File
@@ -18,6 +18,17 @@ use std::sync::Arc;
static NEXT_DOCUMENT_TAB_ID: AtomicU64 = AtomicU64::new(1); 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 ────────────────────────────────────────────────────────── // ── Dynamic input ──────────────────────────────────────────────────────────
/// One quantity shown in the dynamic-input overlay near the cursor. /// 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 /// Monotonic committed-edit/undo/redo revision. Background save completion
/// uses it with scene epochs so an older snapshot never clears newer work. /// uses it with scene epochs so an older snapshot never clears newer work.
pub(super) edit_revision: u64, 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) tab_title: String,
pub(super) properties: PropertiesPanel, pub(super) properties: PropertiesPanel,
pub(super) layers: LayerPanel, pub(super) layers: LayerPanel,
@@ -630,6 +648,7 @@ impl DocumentTab {
dirty: false, dirty: false,
recovery_save_as_required: false, recovery_save_as_required: false,
edit_revision: 0, edit_revision: 0,
document_generation: next_document_generation(),
prev_selection: Vec::new(), prev_selection: Vec::new(),
tab_title: format!("Drawing{}", n), tab_title: format!("Drawing{}", n),
properties: PropertiesPanel::empty(), properties: PropertiesPanel::empty(),
@@ -703,6 +722,14 @@ impl DocumentTab {
t 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 { pub(super) fn tab_display_name(&self) -> String {
match &self.current_path { match &self.current_path {
Some(p) => p Some(p) => p
@@ -777,6 +804,7 @@ impl HistorySnapshot {
.saturating_add(d.named_parameters.as_ref().map_or(0, |(before, after)| { .saturating_add(d.named_parameters.as_ref().map_or(0, |(before, after)| {
before.len().saturating_add(after.len()).saturating_mul(128) before.len().saturating_add(after.len()).saturating_mul(128)
})) }))
.saturating_add(d.reference_designations.len().saturating_mul(16))
.saturating_add(d.label.len()), .saturating_add(d.label.len()),
HistorySnapshot::ObjectVisibility(v) => v HistorySnapshot::ObjectVisibility(v) => v
.before .before
@@ -829,6 +857,12 @@ pub(super) struct DeltaSnapshot {
/// `document.objects`, so it needs its own channel here rather than /// `document.objects`, so it needs its own channel here rather than
/// riding along with `entities`/`structure`. Almost always empty. /// riding along with `entities`/`structure`. Almost always empty.
pub(super) parametric_constraints: Vec<ParametricConstraintsEntryDelta>, pub(super) parametric_constraints: Vec<ParametricConstraintsEntryDelta>,
/// 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<ReferenceDesignationDelta>,
/// Drawing-wide parameter table changed by this transaction. /// Drawing-wide parameter table changed by this transaction.
pub(super) named_parameters: Option<( pub(super) named_parameters: Option<(
crate::scene::named_parameters::ParameterTable, crate::scene::named_parameters::ParameterTable,
@@ -903,6 +937,17 @@ pub(super) struct ParametricConstraintsEntryDelta {
pub(super) after: crate::scene::parametric_constraints::ParametricConstraintSet, 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)] #[derive(Default)]
pub(super) struct HistoryState { pub(super) struct HistoryState {
pub(super) undo_stack: Vec<HistorySnapshot>, pub(super) undo_stack: Vec<HistorySnapshot>,
@@ -918,6 +963,12 @@ pub(super) struct PendingHistorySnapshot {
pub(super) dirty_before: bool, pub(super) dirty_before: bool,
pub(super) structure_before: CadDocument, pub(super) structure_before: CadDocument,
pub(super) recorder: Arc<codec::document::EntityChangeRecorder>, pub(super) recorder: Arc<codec::document::EntityChangeRecorder>,
/// 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. /// Kept as an undo step even when nothing changed.
pub(super) keep_empty: bool, pub(super) keep_empty: bool,
} }
+84 -5
View File
@@ -1,8 +1,8 @@
use super::{ use super::{
document::{ document::{
DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot, DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot,
ParametricConstraintsEntryDelta, PendingHistorySnapshot, StructureSnapshot, ParametricConstraintsEntryDelta, PendingHistorySnapshot, ReferenceDesignationDelta,
TableEntryDelta, StructureSnapshot, TableEntryDelta,
}, },
OpenCADStudio, OpenCADStudio,
}; };
@@ -226,6 +226,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)), structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)),
parametric_constraints: Vec::new(), parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None, named_parameters: None,
label: label.into(), label: label.into(),
}; };
@@ -328,8 +329,26 @@ impl OpenCADStudio {
let selected_after: Vec<Handle> = self.tabs[i].scene.selected.iter().copied().collect(); let selected_after: Vec<Handle> = self.tabs[i].scene.selected.iter().copied().collect();
let dirty_after = self.tabs[i].dirty; let dirty_after = self.tabs[i].dirty;
let active_layer_after = self.tabs[i].active_layer.clone(); 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 if !pending.keep_empty
&& entities.is_empty() && entities.is_empty()
&& reference_designations.is_empty()
&& !structure_changed && !structure_changed
&& pending.selected_before == selected_after && pending.selected_before == selected_after
&& pending.dirty_before == dirty_after && pending.dirty_before == dirty_after
@@ -350,6 +369,7 @@ impl OpenCADStudio {
structure: structure_changed structure: structure_changed
.then_some(StructureSnapshot::Full(pending.structure_before)), .then_some(StructureSnapshot::Full(pending.structure_before)),
parametric_constraints: Vec::new(), parametric_constraints: Vec::new(),
reference_designations,
named_parameters: None, named_parameters: None,
label: pending.label, label: pending.label,
}; };
@@ -390,10 +410,33 @@ impl OpenCADStudio {
dirty_before, dirty_before,
structure_before, structure_before,
recorder, recorder,
reference_designations_before: Vec::new(),
keep_empty: false, 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 /// [`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). /// 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<String>) { pub(super) fn push_undo_mark(&mut self, i: usize, label: impl Into<String>) {
@@ -481,6 +524,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::Layers(entries)), structure: Some(StructureSnapshot::Layers(entries)),
parametric_constraints: Vec::new(), parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None, named_parameters: None,
label: pending.label, label: pending.label,
}; };
@@ -538,6 +582,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::TextStyles(entries)), structure: Some(StructureSnapshot::TextStyles(entries)),
parametric_constraints: Vec::new(), parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None, named_parameters: None,
label: pending.label, label: pending.label,
}; };
@@ -595,6 +640,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::DimStyles(entries)), structure: Some(StructureSnapshot::DimStyles(entries)),
parametric_constraints: Vec::new(), parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None, named_parameters: None,
label: pending.label, label: pending.label,
}; };
@@ -660,6 +706,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure: Some(StructureSnapshot::Objects(entries)), structure: Some(StructureSnapshot::Objects(entries)),
parametric_constraints: Vec::new(), parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None, named_parameters: None,
label: pending.label, label: pending.label,
}; };
@@ -695,6 +742,7 @@ impl OpenCADStudio {
object_handles, object_handles,
}), }),
parametric_constraints: Vec::new(), parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None, named_parameters: None,
label: "STYLE".to_string(), label: "STYLE".to_string(),
}; };
@@ -777,8 +825,13 @@ impl OpenCADStudio {
pending.label pending.label
); );
} }
let (entity_before, object_before, parametric_constraints_before, named_parameters_before) = let (
rec.into_recorded_images(); entity_before,
object_before,
parametric_constraints_before,
named_parameters_before,
reference_designations_before,
) = rec.into_recorded_images();
let entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)> = let entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)> =
entity_before entity_before
.into_iter() .into_iter()
@@ -824,6 +877,20 @@ impl OpenCADStudio {
let after = self.tabs[i].scene.named_parameters().clone(); let after = self.tabs[i].scene.named_parameters().clone();
(before != after).then_some((before, after)) (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<ReferenceDesignationDelta> = 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 selected_after = self.tabs[i].scene.selected.iter().copied().collect();
let dirty_after = self.tabs[i].dirty; let dirty_after = self.tabs[i].dirty;
let mut structure = pending.structure_before.map(StructureSnapshot::Full); let mut structure = pending.structure_before.map(StructureSnapshot::Full);
@@ -875,6 +942,7 @@ impl OpenCADStudio {
active_layer: None, active_layer: None,
structure, structure,
parametric_constraints, parametric_constraints,
reference_designations,
named_parameters, named_parameters,
label: pending.label, 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; 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() { if !d.parametric_constraints.is_empty() {
scene.bump_constraints_epoch(); scene.bump_constraints_epoch();
} }
+8
View File
@@ -1153,6 +1153,12 @@ pub(super) struct OpenCADStudio {
/// boundaries as well as after plugin-initiated writes. /// boundaries as well as after plugin-initiated writes.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
last_plugin_document: Option<(u64, u64)>, 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 /// External add-on packages found in the plugins folder, refreshed when the
/// Plugin Manager opens. /// Plugin Manager opens.
external_plugins: Vec<crate::plugin::external::ExternalPlugin>, external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
@@ -4832,6 +4838,8 @@ impl OpenCADStudio {
last_plugin_selection: None, last_plugin_selection: None,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
last_plugin_document: None, last_plugin_document: None,
#[cfg(not(target_arch = "wasm32"))]
last_plugin_document_opened: None,
external_plugins: Vec::new(), external_plugins: Vec::new(),
loaded_plugin_ids: rustc_hash::FxHashSet::default(), loaded_plugin_ids: rustc_hash::FxHashSet::default(),
plugin_load_errors: rustc_hash::FxHashMap::default(), plugin_load_errors: rustc_hash::FxHashMap::default(),
+406 -8
View File
@@ -107,7 +107,7 @@ impl<'a> HostSession<'a> {
*slot = attribute; *slot = attribute;
self.app.tabs[self.tab] self.app.tabs[self.tab]
.scene .scene
.update_entity(EntityType::Insert(insert)) .update_entity_plugin(EntityType::Insert(insert))
} }
pub fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> { pub fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
@@ -142,6 +142,11 @@ impl<'a> HostSession<'a> {
let key = (self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch); let key = (self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch);
if self.app.last_plugin_document != Some(key) { if self.app.last_plugin_document != Some(key) {
v4_support::publish_drawing_changed(key.0, key.1); 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); v4_support::publish_document_view_v4(self.tab_id(), doc);
self.app.last_plugin_document = self.app.last_plugin_document =
@@ -373,7 +378,7 @@ impl<'a> HostSession<'a> {
insert.attributes.push(attribute); insert.attributes.push(attribute);
if !self.app.tabs[self.tab] if !self.app.tabs[self.tab]
.scene .scene
.update_entity(EntityType::Insert(insert)) .update_entity_plugin(EntityType::Insert(insert))
{ {
return Handle::NULL; return Handle::NULL;
} }
@@ -415,7 +420,7 @@ impl<'a> HostSession<'a> {
} }
let ok = match entity { let ok = match entity {
EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), 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); self.app.sync_registered_layers(self.tab);
if ok { if ok {
@@ -473,7 +478,7 @@ impl<'a> HostSession<'a> {
// the document exclusively. // the document exclusively.
let updated = match entity { let updated = match entity {
EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), 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); assert!(updated);
} }
@@ -519,6 +524,28 @@ impl<'a> HostSession<'a> {
Ok(()) 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 /// 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 /// sync. Returns `false` when the entity is absent or on a locked layer
/// (which `erase_entities` refuses to remove). /// (which `erase_entities` refuses to remove).
@@ -548,7 +575,7 @@ impl<'a> HostSession<'a> {
.document() .document()
.get_entity(handle) .get_entity(handle)
.map(|entity| entity.common().owner_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(); let removed = self.document().get_entity(handle).is_none();
if removed { if removed {
// The document keeps a block record's member list separately from the // 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()); return Err("the result could not be added".into());
} }
if delete_source { if delete_source {
self.app.tabs[self.tab].scene.erase_entities(&[source]); self.erase_entities_recording_designations(&[source]);
self.publish_document_view(); self.publish_document_view();
} }
Ok(handle) Ok(handle)
@@ -1401,7 +1428,7 @@ impl<'a> HostSession<'a> {
self.push_undo("Delete layer"); self.push_undo("Delete layer");
self.document_mut().layers.remove(&name); self.document_mut().layers.remove(&name);
if !on_layer.is_empty() { 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]); self.finish_layer_change(&[name]);
Ok(handle) Ok(handle)
@@ -2256,7 +2283,7 @@ impl<'a> HostSession<'a> {
} }
} }
if erase_originals { if erase_originals {
self.app.tabs[self.tab].scene.erase_entities(&entities); self.erase_entities_recording_designations(&entities);
} }
self.finish_block_change(); self.finish_block_change();
Ok(handle) Ok(handle)
@@ -3123,6 +3150,76 @@ impl<'a> HostSession<'a> {
.collect()) .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 /// Resolve an existing XRecord to its containing dictionary, its validated
/// name and its handle, after validating the path, the name and the target /// name and its handle, after validating the path, the name and the target
/// kind. /// kind.
@@ -3526,6 +3623,9 @@ impl HostApi for HostSession<'_> {
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> { fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
self.list_xrecords(dictionary_path) 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) /// 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))); 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] #[test]
fn add_and_update_auto_register_novel_layers_with_real_handles() { fn add_and_update_auto_register_novel_layers_with_real_handles() {
// A plugin adds/edits an entity naming a layer no LAYER command ever // A plugin adds/edits an entity naming a layer no LAYER command ever
+12 -5
View File
@@ -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. /// reachable from Options and from the sysvar of the same name.
pub(super) fn refresh_selected_grips(&mut self) { pub(super) fn refresh_selected_grips(&mut self) {
let i = self.active_tab; 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| { let locked_active_grip = self.tabs[i].active_grip.as_ref().is_some_and(|grip| {
grip.targets grip.targets.iter().any(|target| {
.iter() self.tabs[i].scene.is_layer_locked(target.handle)
.any(|target| self.tabs[i].scene.is_layer_locked(target.handle)) || self.tabs[i].scene.is_reference_only(target.handle)
})
}); });
if locked_active_grip { if locked_active_grip {
self.cancel_active_grip_edit(); 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 annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle();
let selected = self.tabs[i].scene.selected_entities(); let selected = self.tabs[i].scene.selected_entities();
let single_handle = (selected.len() == 1 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); .then(|| selected[0].0);
let mut grips = Vec::new(); let mut grips = Vec::new();
let mut handles = 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 selected
}; };
for (handle, entity) in 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; continue;
} }
let contextual = crate::scene::annotative::entity_for_annotation_context( let contextual = crate::scene::annotative::entity_for_annotation_context(
+73 -10
View File
@@ -226,7 +226,7 @@ fn record(
value value
} }
fn entity_record(entity: &codec::EntityType) -> Result<Value, String> { fn entity_record(entity: &codec::EntityType, reference_only: bool) -> Result<Value, String> {
let (kind, properties) = enum_parts(entity)?; let (kind, properties) = enum_parts(entity)?;
let mut value = record( let mut value = record(
"entities", "entities",
@@ -245,6 +245,9 @@ fn entity_record(entity: &codec::EntityType) -> Result<Value, String> {
"record_type".into(), "record_type".into(),
json!(crate::entities::names::dxf_name(entity)), 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) Ok(value)
} }
@@ -305,12 +308,13 @@ fn map_records<T: Serialize>(
.collect() .collect()
} }
fn collection_records( fn collection_records(scene: &crate::scene::Scene, collection: &str) -> Result<Vec<Value>, String> {
document: &codec::CadDocument, let document = &scene.document;
collection: &str,
) -> Result<Vec<Value>, String> {
match collection { 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" => { "objects" => {
let mut objects: Vec<_> = document.objects.iter().collect(); let mut objects: Vec<_> = document.objects.iter().collect();
objects.sort_by_key(|(handle, _)| handle.value()); objects.sort_by_key(|(handle, _)| handle.value());
@@ -1385,7 +1389,7 @@ impl OpenCADStudio {
} }
let definitions = referenced_definitions(&root_type); let definitions = referenced_definitions(&root_type);
let runtime_schema = collection.and_then(|collection| { 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()? .ok()?
.into_iter() .into_iter()
.find(|record| { .find(|record| {
@@ -1429,20 +1433,20 @@ impl OpenCADStudio {
} }
pub(super) fn record_query(&self, request: &Value) -> Value { 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 { let Some(collection) = request["collection"].as_str() else {
return self.record_capabilities(); return self.record_capabilities();
}; };
let mut records = Vec::new(); let mut records = Vec::new();
if collection == "all" { if collection == "all" {
for (name, _) in COLLECTIONS { for (name, _) in COLLECTIONS {
match collection_records(document, name) { match collection_records(scene, name) {
Ok(mut values) => records.append(&mut values), Ok(mut values) => records.append(&mut values),
Err(error) => return failure("serialization_failed", error), Err(error) => return failure("serialization_failed", error),
} }
} }
} else { } else {
match collection_records(document, collection) { match collection_records(scene, collection) {
Ok(values) => records = values, Ok(values) => records = values,
Err(error) => return failure("unknown_collection", error), Err(error) => return failure("unknown_collection", error),
} }
@@ -1506,6 +1510,12 @@ impl OpenCADStudio {
if self.tabs[i].scene.is_layer_locked(handle) { if self.tabs[i].scene.is_layer_locked(handle) {
return Err(failure("layer_locked", "entity is on a locked layer")); 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] let source = self.tabs[i]
.scene .scene
.document .document
@@ -1692,6 +1702,59 @@ mod tests {
app.automation_op(&request.to_string()) 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] #[test]
fn records_query_and_edit_every_mutable_record_family() { fn records_query_and_edit_every_mutable_record_family() {
let mut app = OpenCADStudio::new_for_test(); let mut app = OpenCADStudio::new_for_test();
+4
View File
@@ -2276,6 +2276,10 @@ impl OpenCADStudio {
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1); .set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
} }
self.opening.take(); 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 // The tab list just changed (and the active tab with it): remember
// it for the next boot's restore flow (web session memory). // it for the next boot's restore flow (web session memory).
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
+38
View File
@@ -312,6 +312,11 @@ impl OpenCADStudio {
self.last_plugin_selection = Some(key); self.last_plugin_selection = Some(key);
let handles = self.tabs[i].scene.selected_handles_in_order(); let handles = self.tabs[i].scene.selected_handles_in_order();
crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles.clone()); 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); self.control_record_selection_event(&handles);
} }
@@ -331,6 +336,35 @@ impl OpenCADStudio {
self.last_plugin_document = Some(key); self.last_plugin_document = Some(key);
crate::plugin::v4_support::publish_drawing_changed(tab.id, tab.scene.geometry_epoch); 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); crate::plugin::v4_support::publish_document_view_v4(tab.id, &tab.scene.document);
// Same guarded boundary, second surface (API v9): the plain
// `DocumentChanged` carries the settled geometry epoch to every
// loaded native plugin through `BuiltinPlugin::on_notification`.
crate::plugin::v4_support::publish_document_changed(tab.scene.geometry_epoch);
}
/// Announce that a whole document became resident in the active tab —
/// opened from file, created from a template, or loaded by a system open
/// (API v9). Keyed on `(tab id, document generation)`, which changes only
/// when a tab's content is wholesale replaced, so an edit can never fire
/// it twice and a reload fires it exactly once. Part of the same
/// post-message boundary as the selection and document dispatches: runs
/// after the mutation settles and pending history scopes closed, never
/// inside history replay or interactive picking.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn notify_plugins_document_opened(&mut self) {
if self.active_tab >= self.tabs.len() {
return;
}
let i = self.active_tab;
if self.tabs[i].is_start {
return;
}
let key = (self.tabs[i].id, self.tabs[i].document_generation);
if self.last_plugin_document_opened == Some(key) {
return;
}
self.last_plugin_document_opened = Some(key);
crate::plugin::v4_support::publish_document_opened(self.tabs[i].id);
} }
pub fn update(&mut self, msg: Message) -> Task<Message> { pub fn update(&mut self, msg: Message) -> Task<Message> {
@@ -577,6 +611,10 @@ impl OpenCADStudio {
self.notify_plugins_selection_changed(); self.notify_plugins_selection_changed();
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_document_changed(); 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 // OTRACK acquires tracking points only while a command or grip drag is
// running; drop them once neither is active so the temporary tracking // running; drop them once neither is active so the temporary tracking
// points / vectors disappear when the command ends (issue #64). A grip // points / vectors disappear when the command ends (issue #64). A grip
+4
View File
@@ -736,6 +736,9 @@ impl OpenCADStudio {
bounds, bounds,
candidate_handles.as_ref(), 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); timing.hit = elapsed(t_hit);
(handles, timing) (handles, timing)
} }
@@ -5308,6 +5311,7 @@ properties={:.1}ms picked={}",
.into_iter() .into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s)) .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.passes_selection_filter(h))
.filter(|&h| !self.tabs[i].scene.is_reference_only(h))
.collect(); .collect();
if cands.len() >= 2 { if cands.len() >= 2 {
// Overlap: open the list box at the cursor. // Overlap: open the list box at the cursor.
+14
View File
@@ -3598,6 +3598,7 @@ fn apply_dimension_jog(
} }
pub trait DimensionTess { pub trait DimensionTess {
#[allow(clippy::too_many_arguments)]
fn tessellate( fn tessellate(
&self, &self,
document: &CadDocument, document: &CadDocument,
@@ -3611,10 +3612,12 @@ pub trait DimensionTess {
bg_color: [f32; 4], bg_color: [f32; 4],
view_aabb: Option<[f32; 4]>, view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>, world_per_pixel: Option<f32>,
reference_only: &rustc_hash::FxHashSet<codec::Handle>,
) -> Vec<WireModel>; ) -> Vec<WireModel>;
} }
impl DimensionTess for Dimension { impl DimensionTess for Dimension {
#[allow(clippy::too_many_arguments)]
fn tessellate( fn tessellate(
&self, &self,
document: &CadDocument, document: &CadDocument,
@@ -3628,6 +3631,7 @@ impl DimensionTess for Dimension {
bg_color: [f32; 4], bg_color: [f32; 4],
view_aabb: Option<[f32; 4]>, view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>, world_per_pixel: Option<f32>,
reference_only: &rustc_hash::FxHashSet<codec::Handle>,
) -> Vec<WireModel> { ) -> Vec<WireModel> {
tessellate_dimension_inner( tessellate_dimension_inner(
document, document,
@@ -3642,10 +3646,12 @@ impl DimensionTess for Dimension {
bg_color, bg_color,
view_aabb, view_aabb,
world_per_pixel, world_per_pixel,
reference_only,
) )
} }
} }
#[allow(clippy::too_many_arguments)]
fn tessellate_dimension_inner( fn tessellate_dimension_inner(
document: &CadDocument, document: &CadDocument,
handle: Handle, handle: Handle,
@@ -3662,6 +3668,7 @@ fn tessellate_dimension_inner(
bg_color: [f32; 4], bg_color: [f32; 4],
view_aabb: Option<[f32; 4]>, view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>, world_per_pixel: Option<f32>,
reference_only: &rustc_hash::FxHashSet<codec::Handle>,
) -> Vec<WireModel> { ) -> Vec<WireModel> {
if let Dimension::Arc(arc) = dim { if let Dimension::Arc(arc) = dim {
if let Some((local_arc, normal)) = arc_dimension_in_ocs(arc) { if let Some((local_arc, normal)) = arc_dimension_in_ocs(arc) {
@@ -3678,6 +3685,7 @@ fn tessellate_dimension_inner(
bg_color, bg_color,
None, None,
world_per_pixel, world_per_pixel,
reference_only,
); );
for wire in &mut wires { for wire in &mut wires {
map_wire_ocs_to_wcs(wire, normal); map_wire_ocs_to_wcs(wire, normal);
@@ -3700,6 +3708,7 @@ fn tessellate_dimension_inner(
bg_color, bg_color,
None, None,
world_per_pixel, world_per_pixel,
reference_only,
); );
for wire in &mut wires { for wire in &mut wires {
map_wire_ocs_to_wcs(wire, normal); map_wire_ocs_to_wcs(wire, normal);
@@ -3721,6 +3730,7 @@ fn tessellate_dimension_inner(
bg_color, bg_color,
None, None,
world_per_pixel, world_per_pixel,
reference_only,
); );
for wire in &mut wires { for wire in &mut wires {
map_wire_ocs_to_wcs(wire, normal); map_wire_ocs_to_wcs(wire, normal);
@@ -4420,6 +4430,7 @@ fn tessellate_dimension_inner(
view_aabb, view_aabb,
world_per_pixel, world_per_pixel,
text_color, text_color,
reference_only,
); );
for mut w in text_wires { for mut w in text_wires {
w.name = name.clone(); w.name = name.clone();
@@ -4437,6 +4448,7 @@ fn tessellate_dimension_inner(
view_aabb, view_aabb,
world_per_pixel, world_per_pixel,
text_color, text_color,
reference_only,
); );
for mut w in tol_wires { for mut w in tol_wires {
w.name = name.clone(); w.name = name.clone();
@@ -8890,6 +8902,7 @@ mod linear_transform_tests {
[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0],
None, None,
None, None,
&rustc_hash::FxHashSet::default(),
); );
let mut points = Vec::new(); let mut points = Vec::new();
for wire in &wires { for wire in &wires {
@@ -9221,6 +9234,7 @@ mod layout_parity_tests {
[0.0, 0.0, 0.0, 1.0], [0.0, 0.0, 0.0, 1.0],
None, None,
None, None,
&rustc_hash::FxHashSet::default(),
); );
let mut points = Vec::new(); let mut points = Vec::new();
let mut fills = 0; let mut fills = 0;
+1
View File
@@ -2344,6 +2344,7 @@ impl MultiLeaderTess for MultiLeader {
depth_override: None, depth_override: None,
display_visible: true, display_visible: true,
snap_only: false, snap_only: false,
reference_only: false,
plot_visible: true, plot_visible: true,
fill_is_3d: false, fill_is_3d: false,
fill_is_2d_solid: false, fill_is_2d_solid: false,
+76
View File
@@ -48,6 +48,31 @@ pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec<codec::Handle>) {
broadcast(HostNotification::SelectionChangedV4 { tab_id, handles }); broadcast(HostNotification::SelectionChangedV4 { tab_id, handles });
} }
/// Broadcast the tab-independent `HostNotification::SelectionChanged` to every
/// loaded native plugin (API v9): the plain-handle variant of the selection
/// event, dispatched at the same fingerprint-guarded message boundary as the
/// tab-scoped one above (`OpenCADStudio::notify_plugins_selection_changed`).
pub fn publish_selection_changed(handles: Vec<codec::Handle>) {
broadcast(HostNotification::SelectionChanged { handles });
}
/// Broadcast the tab-independent `HostNotification::DocumentChanged` to every
/// loaded native plugin (API v9): the geometry epoch of the active tab's
/// document after a mutation settled, dispatched at the fingerprint-guarded
/// message boundary (`OpenCADStudio::notify_plugins_document_changed`).
pub fn publish_document_changed(version: u64) {
broadcast(HostNotification::DocumentChanged { version });
}
/// Broadcast `HostNotification::DocumentOpened` to every loaded native plugin
/// (API v9): a whole document became resident in a tab — opened from file,
/// created from a template, or loaded by a system open. The counterpart of
/// [`HostNotification::DocumentTabClosed`], so a plugin can re-seed runtime
/// per-document state (e.g. reference-only designations) from load to close.
pub fn publish_document_opened(tab_id: u64) {
broadcast(HostNotification::DocumentOpened { tab_id });
}
/// Broadcast a change in a tab's active interactive command. /// Broadcast a change in a tab's active interactive command.
pub fn publish_command_state_changed(tab_id: u64, command: Option<String>) { pub fn publish_command_state_changed(tab_id: u64, command: Option<String>) {
broadcast(HostNotification::CommandStateChanged { tab_id, command }); broadcast(HostNotification::CommandStateChanged { tab_id, command });
@@ -71,7 +96,58 @@ pub fn notification_handler() -> NotificationHandler {
} }
fn broadcast(notification: HostNotification) { fn broadcast(notification: HostNotification) {
#[cfg(test)]
record_test_dispatch(&notification);
crate::plugin::external::with_manager(|mgr| { crate::plugin::external::with_manager(|mgr| {
mgr.broadcast_notification(None, notification); mgr.broadcast_notification(None, notification);
}); });
} }
// ── Test-only dispatch observation ──────────────────────────────────────────
//
// App-level tests assert boundary dispatch behaviour through this per-thread
// log: `cargo test` runs each test on its own thread, so a thread-local log
// records exactly the notifications this test's app instance broadcast,
// without cross-test interference. One recorded entry stands for one fan-out
// to every loaded native plugin (the manager's broadcast), so counts here are
// counts per plugin.
#[cfg(test)]
thread_local! {
static TEST_DISPATCH_LOG: std::cell::RefCell<Vec<String>> =
const { std::cell::RefCell::new(Vec::new()) };
}
#[cfg(test)]
fn record_test_dispatch(notification: &HostNotification) {
let kind = match notification {
HostNotification::DocumentChanged { version } => {
format!("DocumentChanged({version})")
}
HostNotification::SelectionChanged { handles } => {
format!("SelectionChanged({} handles)", handles.len())
}
HostNotification::DocumentOpened { tab_id } => format!("DocumentOpened({tab_id})"),
HostNotification::DocumentTabClosed { tab_id } => format!("DocumentTabClosed({tab_id})"),
HostNotification::DrawingChanged { tab_id, epoch } => {
format!("DrawingChanged({tab_id}, {epoch})")
}
HostNotification::SelectionChangedV4 { tab_id, .. } => {
format!("SelectionChangedV4({tab_id})")
}
HostNotification::DocumentChangedV4 { tab_id, version } => {
format!("DocumentChangedV4({tab_id}, {version})")
}
HostNotification::CommandStateChanged { tab_id, .. } => {
format!("CommandStateChanged({tab_id})")
}
_ => "Other".to_string(),
};
TEST_DISPATCH_LOG.with(|log| log.borrow_mut().push(kind));
}
/// Drain this thread's recorded plugin-notification broadcasts (test-only).
#[cfg(test)]
pub fn take_test_dispatch_log() -> Vec<String> {
TEST_DISPATCH_LOG.with(|log| std::mem::take(&mut *log.borrow_mut()))
}
+5
View File
@@ -792,6 +792,9 @@ fn tessellate_sub_local(
// top-level entities, applied per-defn. // top-level entities, applied per-defn.
let wires_out = if let EntityType::Dimension(dimension) = sub { let wires_out = if let EntityType::Dimension(dimension) = sub {
use crate::entities::dimension::DimensionTess; 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( dimension.tessellate(
doc, doc,
h, h,
@@ -804,6 +807,7 @@ fn tessellate_sub_local(
bg_color, bg_color,
None, None,
None, None,
&HashSet::default(),
) )
} else { } else {
tessellate::tessellate( tessellate::tessellate(
@@ -1840,6 +1844,7 @@ impl Batches {
depth_override: b.local_depth, depth_override: b.local_depth,
display_visible: !b.hide_unselected || selected, display_visible: !b.hide_unselected || selected,
snap_only: false, snap_only: false,
reference_only: false,
plot_visible: b.plot_visible, plot_visible: b.plot_visible,
fill_is_3d: b.fill_is_3d, fill_is_3d: b.fill_is_3d,
fill_is_2d_solid: b.fill_is_2d_solid, fill_is_2d_solid: b.fill_is_2d_solid,
+24
View File
@@ -473,6 +473,7 @@ fn section_symbol_wires(
/// so it picks up the standard text LOD ladder (baseline / greek / full), /// 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 /// then re-color the returned wires with the dimension's resolved text colour
/// (so DIMCLRT / DIMSTYLE colours win over the synthetic Text's defaults). /// (so DIMCLRT / DIMSTYLE colours win over the synthetic Text's defaults).
#[allow(clippy::too_many_arguments)]
pub(crate) fn tessellate_entity_dim_text( pub(crate) fn tessellate_entity_dim_text(
document: &codec::CadDocument, document: &codec::CadDocument,
selected: &HashSet<Handle>, selected: &HashSet<Handle>,
@@ -483,6 +484,7 @@ pub(crate) fn tessellate_entity_dim_text(
view_aabb: Option<[f32; 4]>, view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>, world_per_pixel: Option<f32>,
text_color: [f32; 4], text_color: [f32; 4],
reference_only: &HashSet<Handle>,
) -> Vec<WireModel> { ) -> Vec<WireModel> {
let mut wires = tessellate_entity( let mut wires = tessellate_entity(
document, document,
@@ -496,6 +498,7 @@ pub(crate) fn tessellate_entity_dim_text(
view_aabb, view_aabb,
world_per_pixel, world_per_pixel,
false, false,
reference_only,
); );
for w in &mut wires { for w in &mut wires {
// Synth dim text carries no real entity colour — paint everything // Synth dim text carries no real entity colour — paint everything
@@ -508,6 +511,7 @@ pub(crate) fn tessellate_entity_dim_text(
} }
wires wires
} }
#[allow(clippy::too_many_arguments)]
pub(crate) fn tessellate_entity( pub(crate) fn tessellate_entity(
document: &codec::CadDocument, document: &codec::CadDocument,
selected: &HashSet<Handle>, selected: &HashSet<Handle>,
@@ -520,6 +524,7 @@ pub(crate) fn tessellate_entity(
view_aabb: Option<[f32; 4]>, view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>, world_per_pixel: Option<f32>,
paper_space: bool, paper_space: bool,
reference_only: &HashSet<Handle>,
) -> Vec<WireModel> { ) -> Vec<WireModel> {
let mut wires = tessellate_entity_inner( let mut wires = tessellate_entity_inner(
document, document,
@@ -533,6 +538,7 @@ pub(crate) fn tessellate_entity(
view_aabb, view_aabb,
world_per_pixel, world_per_pixel,
paper_space, paper_space,
reference_only,
); );
let layer_plottable = document 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 wires
} }
#[allow(clippy::too_many_arguments)]
fn tessellate_entity_inner( fn tessellate_entity_inner(
document: &codec::CadDocument, document: &codec::CadDocument,
selected: &HashSet<Handle>, selected: &HashSet<Handle>,
@@ -567,6 +586,10 @@ fn tessellate_entity_inner(
// Retained through recursive INSERT expansion; PSLTSCALE itself is applied // Retained through recursive INSERT expansion; PSLTSCALE itself is applied
// by the viewport's GPU uniform so it never changes resident wire content. // by the viewport's GPU uniform so it never changes resident wire content.
_paper_space: bool, _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<Handle>,
) -> Vec<WireModel> { ) -> Vec<WireModel> {
let contextual = crate::scene::annotative::entity_for_annotation_context( let contextual = crate::scene::annotative::entity_for_annotation_context(
document, document,
@@ -1019,6 +1042,7 @@ fn tessellate_entity_inner(
bg_color, bg_color,
view_aabb, view_aabb,
world_per_pixel, world_per_pixel,
reference_only,
); );
for w in &mut wires { for w in &mut wires {
w.aci = aci; w.aci = aci;
+2
View File
@@ -1808,6 +1808,7 @@ pub fn tessellate(
depth_override: None, depth_override: None,
display_visible: true, display_visible: true,
snap_only: false, snap_only: false,
reference_only: false,
plot_visible: true, plot_visible: true,
fill_is_3d: false, fill_is_3d: false,
fill_is_2d_solid: false, fill_is_2d_solid: false,
@@ -1974,6 +1975,7 @@ pub fn tessellate(
depth_override: None, depth_override: None,
display_visible: true, display_visible: true,
snap_only: false, snap_only: false,
reference_only: false,
plot_visible: true, plot_visible: true,
fill_is_3d: false, fill_is_3d: false,
fill_is_2d_solid: false, fill_is_2d_solid: false,
+1 -1
View File
@@ -2279,7 +2279,7 @@ mod tests {
scene.sync_diameter_association_angle(dimension); scene.sync_diameter_association_angle(dimension);
let recording = scene.take_undo_recording().expect("undo recording"); let recording = scene.take_undo_recording().expect("undo recording");
let (_, objects, _, _) = recording.into_recorded_images(); let (_, objects, _, _, _) = recording.into_recorded_images();
assert!(objects assert!(objects
.iter() .iter()
.any(|(handle, before)| *handle == association && before.is_some())); .any(|(handle, before)| *handle == association && before.is_some()));
+31 -7
View File
@@ -519,23 +519,47 @@ impl Scene {
/// existing entity. /// existing entity.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))] #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub fn update_entity(&mut self, entity: EntityType) -> bool { pub fn update_entity(&mut self, entity: EntityType) -> bool {
let mut changed = Vec::with_capacity(1); self.update_entities_policy(vec![entity], false) == 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
} }
/// Batched variant of [`update_entity`]: writes every replacement, then /// Batched variant of [`update_entity`]: writes every replacement, then
/// issues a single geometry bump for the whole set instead of one bump per /// issues a single geometry bump for the whole set instead of one bump per
/// entity. /// entity.
pub fn update_entities(&mut self, updates: Vec<EntityType>) -> usize { pub fn update_entities(&mut self, updates: Vec<EntityType>) -> 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<EntityType>) -> 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<EntityType>,
allow_reference_only: bool,
) -> usize {
let mut moved = 0usize; let mut moved = 0usize;
let mut changed = Vec::with_capacity(updates.len()); let mut changed = Vec::with_capacity(updates.len());
let mut block_change = false; let mut block_change = false;
for entity in updates { 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) { if self.update_entity_impl(entity, &mut changed, &mut block_change) {
moved += 1; moved += 1;
} }
+98 -4
View File
@@ -36,6 +36,8 @@ pub enum BlockScalePolicy {
mod block_base_point_tests; mod block_base_point_tests;
mod boundary; mod boundary;
mod camera_ops; mod camera_ops;
#[cfg(test)]
mod reference_only_tests;
pub use camera_ops::{add_background, copy_background, erase_background}; pub use camera_ops::{add_background, copy_background, erase_background};
pub(crate) mod centerline; pub(crate) mod centerline;
pub(crate) mod centermark; pub(crate) mod centermark;
@@ -61,6 +63,7 @@ mod page_setup;
mod paper; mod paper;
mod preview; mod preview;
mod project; mod project;
pub mod reference_only;
mod scene_markers; mod scene_markers;
mod selection; mod selection;
pub mod viewport_dimension_pick; 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 /// Keep construction-history curves in the same per-entity tessellation memo
/// as their parent, so navigation never rebuilds them on every frame. /// 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)] #[allow(clippy::too_many_arguments)]
pub(crate) fn tessellate_entity( pub(crate) fn tessellate_entity(
document: &CadDocument, document: &CadDocument,
@@ -97,6 +104,7 @@ pub(crate) fn tessellate_entity(
view_aabb: Option<[f32; 4]>, view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>, world_per_pixel: Option<f32>,
paper_space: bool, paper_space: bool,
reference_only: &HashSet<Handle>,
) -> Vec<WireModel> { ) -> Vec<WireModel> {
let mut wires = convert::tess::tessellate_entity( let mut wires = convert::tess::tessellate_entity(
document, document,
@@ -110,6 +118,7 @@ pub(crate) fn tessellate_entity(
view_aabb, view_aabb,
world_per_pixel, world_per_pixel,
paper_space, paper_space,
reference_only,
); );
if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) { if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) {
let handle = entity.common().handle; let handle = entity.common().handle;
@@ -161,6 +170,7 @@ pub(crate) fn tessellate_entity(
view_aabb, view_aabb,
world_per_pixel, world_per_pixel,
paper_space, paper_space,
reference_only,
) )
}; };
for wire in &mut construction { for wire in &mut construction {
@@ -760,6 +770,13 @@ pub struct UndoRecording {
>, >,
parametric_constraints_order: Vec<parametric_constraints::ParametricScope>, parametric_constraints_order: Vec<parametric_constraints::ParametricScope>,
named_parameters_before: Option<named_parameters::ParameterTable>, named_parameters_before: Option<named_parameters::ParameterTable>,
/// 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, poisoned: bool,
} }
@@ -775,6 +792,7 @@ impl UndoRecording {
&& self.object_order.is_empty() && self.object_order.is_empty()
&& self.parametric_constraints_order.is_empty() && self.parametric_constraints_order.is_empty()
&& self.named_parameters_before.is_none() && self.named_parameters_before.is_none()
&& self.reference_designations_before.is_empty()
} }
/// Consume the recording directories in deterministic first-touch /// Consume the recording directories in deterministic first-touch
@@ -792,6 +810,7 @@ impl UndoRecording {
parametric_constraints::ParametricConstraintSet, parametric_constraints::ParametricConstraintSet,
)>, )>,
Option<named_parameters::ParameterTable>, Option<named_parameters::ParameterTable>,
Vec<(Handle, bool)>,
) { ) {
let entities = self let entities = self
.order .order
@@ -812,11 +831,13 @@ impl UndoRecording {
.map(|before| (scope, before)) .map(|before| (scope, before))
}) })
.collect(); .collect();
let reference_designations = std::mem::take(&mut self.reference_designations_before);
( (
entities, entities,
objects, objects,
parametric_constraints, parametric_constraints,
self.named_parameters_before, self.named_parameters_before,
reference_designations,
) )
} }
@@ -2138,6 +2159,11 @@ pub struct Scene {
pub selected_constraint: Option<crate::scene::parametric_constraints::ConstraintId>, pub selected_constraint: Option<crate::scene::parametric_constraints::ConstraintId>,
/// Session-only ISOLATEOBJECTS / HIDEOBJECTS state. Never written to DWG/DXF. /// Session-only ISOLATEOBJECTS / HIDEOBJECTS state. Never written to DWG/DXF.
pub object_isolation: ObjectIsolationState, 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 /// Entity handles temporarily removed from the base render while an
/// interactive grip preview draws their live replacement. /// interactive grip preview draws their live replacement.
/// Separate from object isolation so a grip can never activate the /// Separate from object isolation so a grip can never activate the
@@ -2782,6 +2808,7 @@ impl Scene {
selected_order: Vec::new(), selected_order: Vec::new(),
selected_constraint: None, selected_constraint: None,
object_isolation: ObjectIsolationState::default(), object_isolation: ObjectIsolationState::default(),
reference_only: Default::default(),
preview_hidden: HashSet::default(), preview_hidden: HashSet::default(),
command_preview_hidden: HashSet::default(), command_preview_hidden: HashSet::default(),
refedit_keep: None, 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 /// 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 /// group edit, a `*D` block record) that a pure-entity delta cannot
/// restore. The app's per-command predicate keeps this from firing. /// 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() { if !changes.is_empty() {
self.invalidate_dependency_index(); self.invalidate_dependency_index();
} }
@@ -7555,6 +7609,7 @@ impl Scene {
None, None,
None, None,
anno_scale_override.is_some(), anno_scale_override.is_some(),
self.reference_only.handles(),
); );
// Solid B-rep corners snap via the mesh edge list (their wire // Solid B-rep corners snap via the mesh edge list (their wire
// path is an empty shell). Enrich before the memo/assembly split // path is an empty shell). Enrich before the memo/assembly split
@@ -9472,6 +9527,22 @@ impl Scene {
.is_some_and(|keep| !keep.contains(&handle))) .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). /// The name of the locked layer `handle` sits on, if any (for messages).
pub fn locked_layer_name(&self, handle: Handle) -> Option<String> { pub fn locked_layer_name(&self, handle: Handle) -> Option<String> {
let name = self let name = self
@@ -10763,6 +10834,21 @@ impl Scene {
pub fn interaction_candidate_handles( pub fn interaction_candidate_handles(
&self, &self,
candidates: &crate::scene::pick::interaction_index::InteractionCandidates, candidates: &crate::scene::pick::interaction_index::InteractionCandidates,
) -> Option<HashSet<Handle>> {
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<HashSet<Handle>> { ) -> Option<HashSet<Handle>> {
let mut handles: HashSet<Handle> = candidates let mut handles: HashSet<Handle> = candidates
.iter() .iter()
@@ -11008,7 +11094,7 @@ impl Scene {
candidate_handles.as_ref(), 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 handles
} }
@@ -11684,6 +11770,9 @@ impl Scene {
// `world_offset` subtraction even though `current_layout != "Model"`. // `world_offset` subtraction even though `current_layout != "Model"`.
// Decide based on the block being tessellated, not the layout. // Decide based on the block being tessellated, not the layout.
let is_model_block = block_handle == self.model_space_block_handle(); 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" { let bg = if self.current_layout == "Model" {
self.bg_color self.bg_color
} else { } else {
@@ -11800,6 +11889,7 @@ impl Scene {
view_aabb, view_aabb,
wpp, wpp,
paper, paper,
reference_only_set,
); );
(e.common().handle, Arc::new(w)) (e.common().handle, Arc::new(w))
}) })
@@ -11846,6 +11936,7 @@ impl Scene {
view_aabb, view_aabb,
wpp, wpp,
paper, paper,
reference_only_set,
) )
}) })
.collect(); .collect();
@@ -11880,6 +11971,7 @@ impl Scene {
view_aabb, view_aabb,
wpp, wpp,
paper, paper,
reference_only_set,
)), )),
Ok(None) => Some(Vec::new()), Ok(None) => Some(Vec::new()),
Err(()) => None, Err(()) => None,
@@ -12900,6 +12992,7 @@ vis_index={:.1} visible_probe={:.1}",
None, None,
None, None,
paper, paper,
self.reference_only.handles(),
); );
// Same solid-corner snaps as the resident paths, so one-off consumers // Same solid-corner snaps as the resident paths, so one-off consumers
// (overlay changed-wires, edit previews) agree with hit-test/snap. // (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. /// 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 { pub fn layout_create_viewport() -> bool {
LAYOUT_CREATE_VIEWPORT.load(std::sync::atomic::Ordering::Relaxed) 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 handle = scene.add_entity(EntityType::RasterImage(image));
let rec = scene.take_undo_recording().unwrap(); let rec = scene.take_undo_recording().unwrap();
assert!(!rec.is_poisoned()); assert!(!rec.is_poisoned());
let (entities, objects, _parametric_constraints, _named_parameters) = let (entities, objects, _parametric_constraints, _named_parameters, _designations) =
rec.into_recorded_images(); rec.into_recorded_images();
assert_eq!(entities.len(), 1); assert_eq!(entities.len(), 1);
assert_eq!(entities[0].0, handle); assert_eq!(entities[0].0, handle);
@@ -14532,7 +14626,7 @@ mod delta_undo_tests {
scene.erase_entities(&[h1]); scene.erase_entities(&[h1]);
let rec = scene.take_undo_recording().unwrap(); let rec = scene.take_undo_recording().unwrap();
assert!(!rec.is_poisoned()); assert!(!rec.is_poisoned());
let (entities, objects, _parametric_constraints, _named_parameters) = let (entities, objects, _parametric_constraints, _named_parameters, _designations) =
rec.into_recorded_images(); rec.into_recorded_images();
assert_eq!(entities.len(), 1); assert_eq!(entities.len(), 1);
assert_eq!(entities[0].0, h1); assert_eq!(entities[0].0, h1);
+7
View File
@@ -822,6 +822,11 @@ pub struct WireModel {
/// (`display_visible` false) it is what object snaps find; drawn, it is /// (`display_visible` false) it is what object snaps find; drawn, it is
/// display only and snaps skip it too ([`Self::is_display_only`]). /// display only and snaps skip it too ([`Self::is_display_only`]).
pub snap_only: bool, 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. /// Whether this wire is included in plotted output.
pub plot_visible: bool, pub plot_visible: bool,
/// `true` when [`fill_tris`] is a real 3-D surface (PolyfaceMesh / /// `true` when [`fill_tris`] is a real 3-D surface (PolyfaceMesh /
@@ -910,6 +915,7 @@ impl WireModel {
depth_override: None, depth_override: None,
display_visible: true, display_visible: true,
snap_only: false, snap_only: false,
reference_only: false,
plot_visible: true, plot_visible: true,
fill_is_3d: false, fill_is_3d: false,
fill_is_2d_solid: false, fill_is_2d_solid: false,
@@ -1448,6 +1454,7 @@ impl Default for WireModel {
depth_override: None, depth_override: None,
display_visible: true, display_visible: true,
snap_only: false, snap_only: false,
reference_only: false,
plot_visible: true, plot_visible: true,
fill_is_3d: false, fill_is_3d: false,
fill_is_2d_solid: false, fill_is_2d_solid: false,
+2 -2
View File
@@ -4502,7 +4502,7 @@ mod tests {
.take_undo_recording() .take_undo_recording()
.expect("an undo recording should still be open"); .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(); recording.into_recorded_images();
let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect(); let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect();
assert!( assert!(
@@ -4559,7 +4559,7 @@ mod tests {
let recording = scene let recording = scene
.take_undo_recording() .take_undo_recording()
.expect("an undo recording should still be open"); .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(); recording.into_recorded_images();
assert_eq!( assert_eq!(
parametric_constraints.len(), parametric_constraints.len(),
+50 -35
View File
@@ -225,7 +225,10 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
let mut best_dist = f32::MAX; let mut best_dist = f32::MAX;
let mut best: Option<&str> = None; 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 tolerance = pick_tolerance_px(wire, lw_display, base_radius_px);
let mut previous = None; let mut previous = None;
for (index, point) in wire.points.iter().enumerate() { 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 { let Some(wire) = wires.source_wire(segment.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.point_marker.is_some() { if wire.point_marker.is_some() {
@@ -299,7 +302,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
} else { } else {
// Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain. // Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain.
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.point_marker.is_some() { 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 { let Some(wire) = wires.source_wire(triangle.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if let Some(depth) = triangle_ref_hit_depth( if let Some(depth) = triangle_ref_hit_depth(
@@ -373,7 +376,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
} }
} else { } else {
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.fill_tris.is_empty() { 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 { let Some(wire) = wires.source_wire(triangle.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if let Some(depth) = triangle_ref_hit_depth( if let Some(depth) = triangle_ref_hit_depth(
@@ -431,7 +434,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
} }
} else { } else {
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.pick_tris.is_empty() { 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 { let Some(wire) = wires.source_wire(glyph.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
let start = glyph.start as usize; let start = glyph.start as usize;
@@ -491,7 +494,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
} }
} else { } else {
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.text_verts.is_empty() { 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 hits: Vec<(f32, &str)> = Vec::new();
let mut marker_hits: HashMap<&str, f32> = HashMap::default(); 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 tolerance = pick_tolerance_px(wire, lw_display, base_radius_px);
let mut previous = None; let mut previous = None;
for (index, point) in wire.points.iter().enumerate() { 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 { let Some(wire) = wires.source_wire(segment.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.point_marker.is_some() { if wire.point_marker.is_some() {
@@ -600,7 +606,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
})); }));
} else { } else {
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.point_marker.is_some() { 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 { let Some(wire) = wires.source_wire(triangle.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if hits.iter().any(|&(_, name)| name == wire.name) { if hits.iter().any(|&(_, name)| name == wire.name) {
@@ -670,7 +676,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
} }
} else { } else {
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.fill_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { 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 { let Some(wire) = wires.source_wire(triangle.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if hits.iter().any(|&(_, name)| name == wire.name) { if hits.iter().any(|&(_, name)| name == wire.name) {
@@ -730,7 +736,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
} }
} else { } else {
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.pick_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { 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 { let Some(wire) = wires.source_wire(glyph.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if hits.iter().any(|&(_, name)| name == wire.name) { if hits.iter().any(|&(_, name)| name == wire.name) {
@@ -780,7 +786,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
} }
} else { } else {
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.text_verts.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { 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. // path below run identical code.
let hit_name = |segment: &SegmentRef| -> Option<&'a str> { let hit_name = |segment: &SegmentRef| -> Option<&'a str> {
let wire = wires.source_wire(segment.wire)?; let wire = wires.source_wire(segment.wire)?;
if wire.snap_only { if wire.snap_only || wire.reference_only {
return None; return None;
} }
let start = segment.start as usize; 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()) if !seen.contains(wire.name.as_str())
&& marker_segment_hit(wire, view_rot, eye, bounds, |a, b| segment_hits(a, b)) && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| segment_hits(a, b))
&& seen.insert(wire.name.as_str()) && 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 { let Some(wire) = wires.source_wire(triangle.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if already(&wire_hit, triangle.wire) { 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 { let Some(wire) = wires.source_wire(glyph.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if already(&wire_hit, glyph.wire) { 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. // Degenerate point-only wires have no indexed segment or surface primitive.
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.points.len() >= 2 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 { let Some(wire) = wires.source_wire(segment.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
let start = segment.start as usize; 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()); 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()) if !seen.contains(wire.name.as_str())
&& marker_segment_hit(wire, view_rot, eye, bounds, |a, b| cuts(a, b)) && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| cuts(a, b))
&& seen.insert(wire.name.as_str()) && seen.insert(wire.name.as_str())
@@ -1528,7 +1540,7 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>(
return out; return out;
} }
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.points.len() < 2 { 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 { let Some(wire) = wires.source_wire(segment.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
let start = segment.start as usize; 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()); 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()) if !seen.contains(wire.name.as_str())
&& marker_segment_hit(wire, view_rot, eye, bounds, |a, b| { && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| {
point_in_polygon(a, poly) 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 { let Some(wire) = wires.source_wire(triangle.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if seen.contains(wire.name.as_str()) { 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 { let Some(wire) = wires.source_wire(glyph.wire) else {
continue; continue;
}; };
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if seen.contains(wire.name.as_str()) { 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() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
if wire.points.len() >= 2 if wire.points.len() >= 2
@@ -1738,7 +1753,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>(
let mut out = Vec::new(); let mut out = Vec::new();
let mut seen = HashSet::default(); let mut seen = HashSet::default();
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
// Fallback: when wire has no line geometry (e.g. greek text emits // 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(); let mut seen = HashSet::default();
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
let name = wire.name.as_str(); 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 out = Vec::new();
let mut seen = HashSet::default(); let mut seen = HashSet::default();
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
// Same AABB fallback as `box_hit`: when a wire has no line // 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(); let mut seen = HashSet::default();
for wire in wires.iter() { for wire in wires.iter() {
if wire.snap_only { if wire.snap_only || wire.reference_only {
continue; continue;
} }
let aabb_pts: Vec<[f32; 3]>; let aabb_pts: Vec<[f32; 3]>;
+2
View File
@@ -588,6 +588,7 @@ mod tests {
let h = doc.add_entity(donut).unwrap(); let h = doc.add_entity(donut).unwrap();
let entity = doc.get_entity(h).unwrap(); let entity = doc.get_entity(h).unwrap();
let selected = rustc_hash::FxHashSet::default(); let selected = rustc_hash::FxHashSet::default();
let reference_only = rustc_hash::FxHashSet::default();
let wires = crate::scene::convert::tess::tessellate_entity( let wires = crate::scene::convert::tess::tessellate_entity(
&doc, &doc,
&selected, &selected,
@@ -600,6 +601,7 @@ mod tests {
None, None,
None, None,
false, false,
&reference_only,
); );
assert_eq!( assert_eq!(
wires.len(), wires.len(),
+187
View File
@@ -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<Handle>,
}
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<Handle> {
&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<Handle> = Default::default();
let none: rustc_hash::FxHashSet<Handle> = Default::default();
let mut designated: rustc_hash::FxHashSet<Handle> = 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"
);
}
}
}
+109
View File
@@ -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<Handle> {
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<Handle> = 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"
);
}
+32 -4
View File
@@ -204,6 +204,7 @@ impl Scene {
let selected = handles let selected = handles
.into_iter() .into_iter()
.filter(|handle| self.passes_selection_filter(*handle)) .filter(|handle| self.passes_selection_filter(*handle))
.filter(|handle| !self.is_reference_only(*handle))
.filter(|handle| { .filter(|handle| {
self.document.get_entity(*handle).is_some_and(|entity| { self.document.get_entity(*handle).is_some_and(|entity| {
self.resident_entity_visible( self.resident_entity_visible(
@@ -1060,7 +1061,15 @@ impl Scene {
// ── Erase ───────────────────────────────────────────────────────────── // ── Erase ─────────────────────────────────────────────────────────────
pub fn erase_entities(&mut self, handles: &[Handle]) { 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 /// 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, /// The shared removal path keeps first-touch undo state, history objects,
/// groups, selection, and derived caches coherent. /// groups, selection, and derived caches coherent.
pub(crate) fn rollback_new_entities(&mut self, handles: &[Handle]) { 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); let erase_handles = self.handles_expanded_for_leader_annotations(handles);
// One pass over document.objects instead of a scan per erased handle. // One pass over document.objects instead of a scan per erased handle.
@@ -1096,10 +1110,15 @@ impl Scene {
let mut hover_changed = false; let mut hover_changed = false;
for &h in &erase_handles { 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) { if respect_layer_locks && self.is_layer_locked(h) {
continue; continue;
} }
if respect_reference_only && self.is_reference_only(h) {
continue;
}
let settings_handle = self.document.get_entity(h).and_then(|entity| match entity { let settings_handle = self.document.get_entity(h).and_then(|entity| match entity {
EntityType::Extended(extended) => match &extended.data { EntityType::Extended(extended) => match &extended.data {
codec::entities::ExtendedEntityData::SectionObject(data) codec::entities::ExtendedEntityData::SectionObject(data)
@@ -1125,6 +1144,12 @@ impl Scene {
let before = self.document.objects.get(&manager_handle).cloned(); let before = self.document.objects.get(&manager_handle).cloned();
self.record_undo_object_before(manager_handle, before); 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); self.delete_solid_history(h);
if let Some(settings_handle) = settings_handle { if let Some(settings_handle) = settings_handle {
@@ -1143,6 +1168,9 @@ impl Scene {
} }
self.remember_removed_cache_categories(h); self.remember_removed_cache_categories(h);
self.document.remove_entity_arc(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); selection_changed |= self.selected.remove(&h);
self.selected_order.retain(|selected| *selected != h); self.selected_order.retain(|selected| *selected != h);
if self.hover_highlight == Some(h) { if self.hover_highlight == Some(h) {
+1
View File
@@ -382,6 +382,7 @@ pub fn apply_along(
depth_override: None, depth_override: None,
display_visible: true, display_visible: true,
snap_only: false, snap_only: false,
reference_only: false,
plot_visible: true, plot_visible: true,
fill_is_3d: false, fill_is_3d: false,
fill_is_2d_solid: false, fill_is_2d_solid: false,
+1
View File
@@ -4177,6 +4177,7 @@ impl Scene {
None, None,
None, None,
content_viewport, content_viewport,
self.reference_only.handles(),
); );
for wire in &mut context_wires { for wire in &mut context_wires {
wire.color = tint; wire.color = tint;