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

+295
View File
@@ -690,6 +690,10 @@ impl OpenCADStudio {
self.notify_plugins_selection_changed();
#[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_document_changed();
// The `new` / `open` / template ops replace a tab's whole document;
// announce it on the same post-op boundary (API v9).
#[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_document_opened();
res
}
@@ -725,6 +729,9 @@ impl OpenCADStudio {
// The headless session starts on the welcome (Start) tab, which
// blocks drawing commands; turn it into a real drawing.
self.tabs[i].is_start = false;
// A fresh document became resident (API v9): mint a new
// generation so the post-op boundary announces `DocumentOpened`.
self.tabs[i].note_document_loaded();
self.entity_summary()
}
#[cfg(not(target_arch = "wasm32"))]
@@ -763,6 +770,10 @@ impl OpenCADStudio {
self.tabs[i].current_path = Some(path_buf);
self.tabs[i].is_start = false;
self.tabs[i].scene.rebuild_derived_caches();
// The opened document settled (API v9): mint a new
// generation so the post-op boundary announces
// `DocumentOpened` once for this load.
self.tabs[i].note_document_loaded();
let mut summary = self.entity_summary();
if dropped > 0 {
if let Some(obj) = summary.as_object_mut() {
@@ -1739,6 +1750,290 @@ mod tests {
app.notify_plugins_document_changed();
assert_eq!(app.last_plugin_document, Some(published));
}
// ── Native-plugin notification dispatch (API v9) ─────────────────────
//
// The app-level tests observe the dispatch seam in
// `plugin::v4_support`: one recorded entry stands for one broadcast to
// every loaded native plugin, so counts here are counts per plugin. The
// write-semantics rule under test: dispatch happens at the message
// boundary — after the mutation settled, never mid-undo/mid-pick — so
// handlers may write through the plugin host API, and the fingerprint
// guards plus idempotent re-marks keep that loop finite.
fn plugin_dispatch_log() -> Vec<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 serde_json::{json, Value};
+1
View File
@@ -223,6 +223,7 @@ impl OpenCADStudio {
active_layer,
structure,
parametric_constraints,
reference_designations: Vec::new(),
named_parameters,
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].is_start = false;
self.tabs[i].dirty = true;
// A whole document just became resident in the tab (API v9): mint a
// fresh generation so the boundary announces `DocumentOpened` once.
self.tabs[i].note_document_loaded();
Ok(purged)
}
+51
View File
@@ -18,6 +18,17 @@ use std::sync::Arc;
static NEXT_DOCUMENT_TAB_ID: AtomicU64 = AtomicU64::new(1);
/// Monotonic source for `DocumentTab::document_generation` (API v9): every
/// minted value is process-unique, so any freshly installed document — file
/// open, template new, system open — differs from every generation a plugin
/// has been told about, and the opened-notification guard fires exactly once
/// per load.
static NEXT_DOCUMENT_GENERATION: AtomicU64 = AtomicU64::new(1);
fn next_document_generation() -> u64 {
NEXT_DOCUMENT_GENERATION.fetch_add(1, Ordering::Relaxed)
}
// ── Dynamic input ──────────────────────────────────────────────────────────
/// One quantity shown in the dynamic-input overlay near the cursor.
@@ -147,6 +158,13 @@ pub(super) struct DocumentTab {
/// Monotonic committed-edit/undo/redo revision. Background save completion
/// uses it with scene epochs so an older snapshot never clears newer work.
pub(super) edit_revision: u64,
/// Monotonic identity of the document currently resident in this tab: a
/// fresh value is minted whenever a whole document replaces the tab's
/// content (file open, template new, system open), and edits leave it
/// alone. The plugin-opened notification guard
/// (`OpenCADStudio::notify_plugins_document_opened`, API v9) keys on it
/// so `DocumentOpened` dispatches exactly once per load.
pub(super) document_generation: u64,
pub(super) tab_title: String,
pub(super) properties: PropertiesPanel,
pub(super) layers: LayerPanel,
@@ -630,6 +648,7 @@ impl DocumentTab {
dirty: false,
recovery_save_as_required: false,
edit_revision: 0,
document_generation: next_document_generation(),
prev_selection: Vec::new(),
tab_title: format!("Drawing{}", n),
properties: PropertiesPanel::empty(),
@@ -703,6 +722,14 @@ impl DocumentTab {
t
}
/// A whole document just replaced this tab's content (file open, template
/// new, system open): mint a fresh generation so the message-boundary
/// dispatch announces `DocumentOpened` exactly once for this load (API
/// v9). Call after the document and its derived caches have settled.
pub(super) fn note_document_loaded(&mut self) {
self.document_generation = next_document_generation();
}
pub(super) fn tab_display_name(&self) -> String {
match &self.current_path {
Some(p) => p
@@ -777,6 +804,7 @@ impl HistorySnapshot {
.saturating_add(d.named_parameters.as_ref().map_or(0, |(before, after)| {
before.len().saturating_add(after.len()).saturating_mul(128)
}))
.saturating_add(d.reference_designations.len().saturating_mul(16))
.saturating_add(d.label.len()),
HistorySnapshot::ObjectVisibility(v) => v
.before
@@ -829,6 +857,12 @@ pub(super) struct DeltaSnapshot {
/// `document.objects`, so it needs its own channel here rather than
/// riding along with `entities`/`structure`. Almost always empty.
pub(super) parametric_constraints: Vec<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.
pub(super) named_parameters: Option<(
crate::scene::named_parameters::ParameterTable,
@@ -903,6 +937,17 @@ pub(super) struct ParametricConstraintsEntryDelta {
pub(super) after: crate::scene::parametric_constraints::ParametricConstraintSet,
}
/// One reference-only designation's before/after value within an entity delta
/// (API v9). The designation set lives on `Scene`, not in the document, so
/// the value rides its own channel — symmetric like every other delta field,
/// undo applies the before value, redo the after value.
#[derive(Clone)]
pub(super) struct ReferenceDesignationDelta {
pub(super) handle: Handle,
pub(super) before: bool,
pub(super) after: bool,
}
#[derive(Default)]
pub(super) struct HistoryState {
pub(super) undo_stack: Vec<HistorySnapshot>,
@@ -918,6 +963,12 @@ pub(super) struct PendingHistorySnapshot {
pub(super) dirty_before: bool,
pub(super) structure_before: CadDocument,
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.
pub(super) keep_empty: bool,
}
+84 -5
View File
@@ -1,8 +1,8 @@
use super::{
document::{
DeltaSnapshot, HistorySnapshot, ObjectEntryDelta, ObjectVisibilitySnapshot,
ParametricConstraintsEntryDelta, PendingHistorySnapshot, StructureSnapshot,
TableEntryDelta,
ParametricConstraintsEntryDelta, PendingHistorySnapshot, ReferenceDesignationDelta,
StructureSnapshot, TableEntryDelta,
},
OpenCADStudio,
};
@@ -226,6 +226,7 @@ impl OpenCADStudio {
active_layer: None,
structure: (!objects.is_empty()).then_some(StructureSnapshot::Objects(objects)),
parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None,
label: label.into(),
};
@@ -328,8 +329,26 @@ impl OpenCADStudio {
let selected_after: Vec<Handle> = self.tabs[i].scene.selected.iter().copied().collect();
let dirty_after = self.tabs[i].dirty;
let active_layer_after = self.tabs[i].active_layer.clone();
// The designation set is scene state mutated through the plugin API:
// a scope that (un)marked entities carries the first-touch before
// values here; the after value is whatever the set holds now, and
// only untouched-value entries drop out (an idempotent re-mark never
// got recorded in the first place).
let reference_designations: Vec<_> = pending
.reference_designations_before
.into_iter()
.filter_map(|(handle, before)| {
let after = self.tabs[i].scene.reference_only.is_marked(handle);
(before != after).then_some(ReferenceDesignationDelta {
handle,
before,
after,
})
})
.collect();
if !pending.keep_empty
&& entities.is_empty()
&& reference_designations.is_empty()
&& !structure_changed
&& pending.selected_before == selected_after
&& pending.dirty_before == dirty_after
@@ -350,6 +369,7 @@ impl OpenCADStudio {
structure: structure_changed
.then_some(StructureSnapshot::Full(pending.structure_before)),
parametric_constraints: Vec::new(),
reference_designations,
named_parameters: None,
label: pending.label,
};
@@ -390,10 +410,33 @@ impl OpenCADStudio {
dirty_before,
structure_before,
recorder,
reference_designations_before: Vec::new(),
keep_empty: false,
});
}
/// Record the pre-mutation value of a reference-only designation (API v9)
/// into the open plugin pending scope — first touch wins. No-op when no
/// scope is open: like the XRecord methods, a designation change must open
/// its own scope before mutating, so by the time this runs a scope either
/// covers it or the caller owns one.
pub(super) fn record_reference_designation_before_pending(
&mut self,
i: usize,
handle: Handle,
before: bool,
) {
if let Some(pending) = self.tabs[i].history.pending.as_mut() {
if !pending
.reference_designations_before
.iter()
.any(|(recorded, _)| *recorded == handle)
{
pending.reference_designations_before.push((handle, before));
}
}
}
/// [`Self::push_undo_snapshot`] for a command that is an undo step even
/// when it changes nothing (UNDO and U then name it and undo nothing).
pub(super) fn push_undo_mark(&mut self, i: usize, label: impl Into<String>) {
@@ -481,6 +524,7 @@ impl OpenCADStudio {
active_layer: None,
structure: Some(StructureSnapshot::Layers(entries)),
parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None,
label: pending.label,
};
@@ -538,6 +582,7 @@ impl OpenCADStudio {
active_layer: None,
structure: Some(StructureSnapshot::TextStyles(entries)),
parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None,
label: pending.label,
};
@@ -595,6 +640,7 @@ impl OpenCADStudio {
active_layer: None,
structure: Some(StructureSnapshot::DimStyles(entries)),
parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None,
label: pending.label,
};
@@ -660,6 +706,7 @@ impl OpenCADStudio {
active_layer: None,
structure: Some(StructureSnapshot::Objects(entries)),
parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None,
label: pending.label,
};
@@ -695,6 +742,7 @@ impl OpenCADStudio {
object_handles,
}),
parametric_constraints: Vec::new(),
reference_designations: Vec::new(),
named_parameters: None,
label: "STYLE".to_string(),
};
@@ -777,8 +825,13 @@ impl OpenCADStudio {
pending.label
);
}
let (entity_before, object_before, parametric_constraints_before, named_parameters_before) =
rec.into_recorded_images();
let (
entity_before,
object_before,
parametric_constraints_before,
named_parameters_before,
reference_designations_before,
) = rec.into_recorded_images();
let entities: Vec<(Handle, Option<Arc<EntityType>>, Option<Arc<EntityType>>)> =
entity_before
.into_iter()
@@ -824,6 +877,20 @@ impl OpenCADStudio {
let after = self.tabs[i].scene.named_parameters().clone();
(before != after).then_some((before, after))
});
// Designations recorded by `remove_entities`'s delta path (the
// first-touch before values) close symmetric with the set's current
// state as the after image.
let reference_designations: Vec<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 dirty_after = self.tabs[i].dirty;
let mut structure = pending.structure_before.map(StructureSnapshot::Full);
@@ -875,6 +942,7 @@ impl OpenCADStudio {
active_layer: None,
structure,
parametric_constraints,
reference_designations,
named_parameters,
label: pending.label,
};
@@ -1029,8 +1097,19 @@ impl OpenCADStudio {
}
}
}
let changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo);
let mut changes = self.tabs[i].scene.apply_entity_delta(&d.entities, undo);
let scene = &mut self.tabs[i].scene;
// Designations: restore the chosen side's value on the scene-owned set
// (the entity images above already applied, so a restored entity is
// present before its designation is). Each actual flip joins the
// returned per-handle change list so the deferred cache reseed and
// geometry bump re-stamp that entity's wires.
for entry in &d.reference_designations {
let value = if undo { entry.before } else { entry.after };
if scene.set_reference_only_membership(entry.handle, value) {
changes.push((entry.handle, crate::scene::ChangeKind::Modified));
}
}
if !d.parametric_constraints.is_empty() {
scene.bump_constraints_epoch();
}
+8
View File
@@ -1153,6 +1153,12 @@ pub(super) struct OpenCADStudio {
/// boundaries as well as after plugin-initiated writes.
#[cfg(not(target_arch = "wasm32"))]
last_plugin_document: Option<(u64, u64)>,
/// `(tab id, document generation)` last dispatched as `DocumentOpened` —
/// a tab's generation changes only when a whole document replaced its
/// content, so the opened notification fires once per load, never per
/// edit (API v9).
#[cfg(not(target_arch = "wasm32"))]
last_plugin_document_opened: Option<(u64, u64)>,
/// External add-on packages found in the plugins folder, refreshed when the
/// Plugin Manager opens.
external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
@@ -4832,6 +4838,8 @@ impl OpenCADStudio {
last_plugin_selection: None,
#[cfg(not(target_arch = "wasm32"))]
last_plugin_document: None,
#[cfg(not(target_arch = "wasm32"))]
last_plugin_document_opened: None,
external_plugins: Vec::new(),
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
plugin_load_errors: rustc_hash::FxHashMap::default(),
+406 -8
View File
@@ -107,7 +107,7 @@ impl<'a> HostSession<'a> {
*slot = attribute;
self.app.tabs[self.tab]
.scene
.update_entity(EntityType::Insert(insert))
.update_entity_plugin(EntityType::Insert(insert))
}
pub fn document_view(&mut self) -> Option<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);
if self.app.last_plugin_document != Some(key) {
v4_support::publish_drawing_changed(key.0, key.1);
// The plain `DocumentChanged` follows the same immediate
// publish contract (API v9): a plugin-initiated write settles
// here, so native plugins' `on_notification` sees it now —
// the message boundary's fingerprint guard then dedupes.
v4_support::publish_document_changed(key.1);
}
v4_support::publish_document_view_v4(self.tab_id(), doc);
self.app.last_plugin_document =
@@ -373,7 +378,7 @@ impl<'a> HostSession<'a> {
insert.attributes.push(attribute);
if !self.app.tabs[self.tab]
.scene
.update_entity(EntityType::Insert(insert))
.update_entity_plugin(EntityType::Insert(insert))
{
return Handle::NULL;
}
@@ -415,7 +420,7 @@ impl<'a> HostSession<'a> {
}
let ok = match entity {
EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute),
entity => self.app.tabs[self.tab].scene.update_entity(entity),
entity => self.app.tabs[self.tab].scene.update_entity_plugin(entity),
};
self.app.sync_registered_layers(self.tab);
if ok {
@@ -473,7 +478,7 @@ impl<'a> HostSession<'a> {
// the document exclusively.
let updated = match entity {
EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute),
entity => self.app.tabs[self.tab].scene.update_entity(entity),
entity => self.app.tabs[self.tab].scene.update_entity_plugin(entity),
};
assert!(updated);
}
@@ -519,6 +524,28 @@ impl<'a> HostSession<'a> {
Ok(())
}
/// Erase through the plugin path while keeping reference-only designations
/// undoable with the erase (API v9). The designation set is scene state,
/// not a document write: erasing drops the set entry in
/// `Scene::remove_entities`, so its first-touch before-image must join the
/// open plugin scope here — otherwise undoing the erase would restore the
/// entity without its designation. No-op for undesignated handles and when
/// no scope is open (then the erase follows the same no-bookkeeping
/// contract as any other entity write).
fn erase_entities_recording_designations(&mut self, handles: &[Handle]) {
for &handle in handles {
if self.app.tabs[self.tab]
.scene
.reference_only
.is_marked(handle)
{
self.app
.record_reference_designation_before_pending(self.tab, handle, true);
}
}
self.app.tabs[self.tab].scene.erase_entities_plugin(handles);
}
/// Delete the entity with `handle`, keeping the scene's render caches in
/// sync. Returns `false` when the entity is absent or on a locked layer
/// (which `erase_entities` refuses to remove).
@@ -548,7 +575,7 @@ impl<'a> HostSession<'a> {
.document()
.get_entity(handle)
.map(|entity| entity.common().owner_handle);
self.app.tabs[self.tab].scene.erase_entities(&[handle]);
self.erase_entities_recording_designations(&[handle]);
let removed = self.document().get_entity(handle).is_none();
if removed {
// The document keeps a block record's member list separately from the
@@ -1138,7 +1165,7 @@ impl<'a> HostSession<'a> {
return Err("the result could not be added".into());
}
if delete_source {
self.app.tabs[self.tab].scene.erase_entities(&[source]);
self.erase_entities_recording_designations(&[source]);
self.publish_document_view();
}
Ok(handle)
@@ -1401,7 +1428,7 @@ impl<'a> HostSession<'a> {
self.push_undo("Delete layer");
self.document_mut().layers.remove(&name);
if !on_layer.is_empty() {
self.app.tabs[self.tab].scene.erase_entities(&on_layer);
self.erase_entities_recording_designations(&on_layer);
}
self.finish_layer_change(&[name]);
Ok(handle)
@@ -2256,7 +2283,7 @@ impl<'a> HostSession<'a> {
}
}
if erase_originals {
self.app.tabs[self.tab].scene.erase_entities(&entities);
self.erase_entities_recording_designations(&entities);
}
self.finish_block_change();
Ok(handle)
@@ -3123,6 +3150,76 @@ impl<'a> HostSession<'a> {
.collect())
}
/// Mark (or unmark) an entity as reference-only (API v9, additive):
/// plugin-owned display geometry the built-in edit paths treat like a
/// locked-layer entity, while the owning plugin keeps full write access
/// through this session. The designation is a scene-scoped in-memory set
/// keyed by handle: nothing is written to the saved DWG/DXF, the set dies
/// with the tab, and a reloaded drawing starts undesignated — the owning
/// plugin re-marks on the `DocumentOpened` notification, which makes the
/// in-memory designation fully equivalent to a persisted one for its use
/// case.
///
/// Every refusal — absent entity, locked layer — is decided before the
/// undo scope is touched, so a failure changes nothing. A call that would
/// not change the current designation succeeds without recording an undo
/// step, without bumping the geometry epoch and without publishing — the
/// idempotence that terminates the re-mark loop driven by the
/// document-changed dispatch. Undo composition: a change inside a
/// plugin-opened `push_undo` scope joins it (see
/// [`HostSession::ensure_xrecord_undo_scope`]); a scope-less call is one
/// undo step of its own. The set is scene state, not a document write, so
/// the undo delta restores it through the explicit designation channel
/// (`DeltaSnapshot::reference_designations`).
pub fn set_reference_only(
&mut self,
handle: Handle,
reference_only: bool,
) -> Result<(), String> {
// Refusals precede any change (and any undo step).
if self.document().get_entity(handle).is_none() {
return Err(format!("entity {handle:?} does not exist"));
}
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
return Err(format!("entity {handle:?} is on a locked layer"));
}
if self.app.tabs[self.tab].scene.is_reference_only(handle) == reference_only {
return Ok(());
}
let own_scope = self.ensure_xrecord_undo_scope("Set Reference-Only");
// First-touch before-image for the pending scope's designation
// channel, recorded before the set changes (see
// `Scene::record_undo_reference_designation_before`).
let before = self.app.tabs[self.tab]
.scene
.reference_only
.is_marked(handle);
self.app
.record_reference_designation_before_pending(self.tab, handle, before);
self.app.tabs[self.tab]
.scene
.set_reference_only_membership(handle, reference_only);
debug_assert!(
self.app.tabs[self.tab]
.scene
.reference_only
.is_marked(handle)
== reference_only,
"designation state was checked before the mutation"
);
// The wires carry the click-through stamp baked at tessellation, so a
// designation change must rebuild them: bump_geometry clears the
// tessellation memos and resident wire sets exactly like an entity
// edit would.
self.bump_geometry();
self.set_dirty();
self.publish_document_view();
if own_scope {
self.app.finish_pending_history(self.tab);
}
Ok(())
}
/// Resolve an existing XRecord to its containing dictionary, its validated
/// name and its handle, after validating the path, the name and the target
/// kind.
@@ -3526,6 +3623,9 @@ impl HostApi for HostSession<'_> {
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
self.list_xrecords(dictionary_path)
}
fn set_reference_only(&mut self, handle: Handle, reference_only: bool) -> Result<(), String> {
self.set_reference_only(handle, reference_only)
}
}
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
@@ -13379,6 +13479,304 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0)))
assert!(!host.update_entity(EntityType::Point(ghost)));
}
fn reference_test_line(end_x: f64, y: f64, handle: Handle) -> EntityType {
let mut line = codec::entities::Line::from_points(
codec::types::Vector3::new(0.0, y, 0.0),
codec::types::Vector3::new(end_x, y, 0.0),
);
line.common.handle = handle;
EntityType::Line(line)
}
#[test]
fn reference_only_refusals_record_no_undo_step() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
// Marking an absent entity refuses.
assert!(host.set_reference_only(Handle::new(999_999), true).is_err());
let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL));
// A locked layer is as unwritable for the designation as for every
// other plugin write.
host.app.tabs[0]
.scene
.document
.layers
.get_mut("0")
.map(|layer| layer.lock());
assert!(host.set_reference_only(handle, true).is_err());
host.app.tabs[0]
.scene
.document
.layers
.get_mut("0")
.map(|layer| layer.unlock());
assert!(
!host.app.tabs[0].scene.is_reference_only(handle),
"refusals must not partially apply the designation"
);
assert_eq!(
host.app.tabs[0].history.undo_stack.len(),
0,
"refusals record no undo step"
);
// An idempotent re-mark succeeds without recording another step.
host.set_reference_only(handle, true).unwrap();
assert_eq!(host.app.tabs[0].history.undo_stack.len(), 1);
host.set_reference_only(handle, true).unwrap();
assert_eq!(
host.app.tabs[0].history.undo_stack.len(),
1,
"an idempotent re-mark records no undo step"
);
}
#[test]
fn reference_only_designation_composes_undo_like_entity_writes() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL));
// A scope-less designation is one undo step of its own.
host.set_reference_only(handle, true).unwrap();
host.set_reference_only(handle, false).unwrap();
assert_eq!(host.app.tabs[0].history.undo_stack.len(), 2);
// Undo reverts the unmark, redo re-applies it.
drop(host);
app.undo_steps(1);
let host = HostSession::new(&mut app, 0);
assert!(host.app.tabs[0].scene.is_reference_only(handle));
drop(host);
app.redo_steps(1);
let mut host = HostSession::new(&mut app, 0);
assert!(!host.app.tabs[0].scene.is_reference_only(handle));
// Inside a plugin-opened push_undo scope the designation joins the
// scope: an entity write plus two markings is ONE step under the
// plugin's label.
host.push_undo("CVI: refresh");
let preview = host.add_entity(EntityType::Point(Point::at(codec::types::Vector3::new(
3.0, 0.0, 0.0,
))));
host.set_reference_only(preview, true).unwrap();
host.set_reference_only(handle, true).unwrap();
drop(host);
app.finish_pending_history(0);
assert_eq!(app.tabs[0].history.undo_stack.len(), 3);
assert_eq!(app.tabs[0].history.undo_stack[2].label(), "CVI: refresh");
// Undoing the plugin operation reverts it whole.
app.undo_steps(1);
let host = HostSession::new(&mut app, 0);
assert!(
host.app.tabs[0]
.scene
.document
.get_entity(preview)
.is_none(),
"undoing the scope removed the entity created inside it"
);
assert!(
!host.app.tabs[0].scene.is_reference_only(handle),
"undoing the scope restored the designation's prior state"
);
}
#[test]
fn reference_only_designation_is_not_written_to_dwg_or_dxf() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL));
host.set_reference_only(handle, true).unwrap();
// The designation lives in the scene's in-memory set only.
assert!(host.app.tabs[0].scene.is_reference_only(handle));
// The host writes nothing of its own into the document: no APPID
// registration, no marker XDATA on the designated entity.
assert!(host.document().app_ids.get("OCS").is_none());
assert!(host
.document()
.get_entity(handle)
.expect("designated entity")
.common()
.extended_data
.get_record("OCS")
.is_none());
let contains_marker =
|bytes: &[u8]| bytes.windows(15).any(|window| window == b"REFERENCE_ONLY");
// DWG: the marker string appears nowhere in the saved bytes, and a
// reopened file carries neither the host APPID nor the marker record.
let dwg = codec::DwgWriter::write_to_vec(host.document()).unwrap();
assert!(
!contains_marker(&dwg),
"DWG save must not contain the designation marker"
);
let reloaded = crate::io::load_bytes("reference-only.dwg", dwg).unwrap();
assert!(reloaded.app_ids.get("OCS").is_none());
assert!(reloaded
.get_entity(handle)
.expect("the line survives the round trip")
.common()
.extended_data
.get_record("OCS")
.is_none());
assert!(
!contains_marker(&codec::DwgWriter::write_to_vec(&reloaded).unwrap()),
"a re-save of the reopened file stays marker-free"
);
// DXF: same contract for the text format.
let dxf = codec::DxfWriter::new(host.document())
.write_to_vec()
.unwrap();
assert!(
!contains_marker(&dxf),
"DXF save must not contain the designation marker"
);
let reloaded = crate::io::load_bytes("reference-only.dxf", dxf).unwrap();
assert!(reloaded.app_ids.get("OCS").is_none());
assert!(reloaded
.get_entity(handle)
.expect("the line survives the round trip")
.common()
.extended_data
.get_record("OCS")
.is_none());
}
#[test]
fn erasing_a_designated_entity_undoes_designation_with_it() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL));
host.set_reference_only(handle, true).unwrap();
// The plugin erase path removes a designated entity inside a scope:
// entity and set entry leave together.
host.push_undo("CVI: replace the underlying model");
host.remove_entity(handle);
drop(host);
app.finish_pending_history(0);
let host = HostSession::new(&mut app, 0);
assert!(host.app.tabs[0].scene.document.get_entity(handle).is_none());
assert!(!host.app.tabs[0].scene.is_reference_only(handle));
drop(host);
// Undoing the erase restores the entity AND its designation: the set
// mutation rode the scope as an explicit designation channel.
app.undo_steps(1);
let host = HostSession::new(&mut app, 0);
assert!(
host.app.tabs[0].scene.document.get_entity(handle).is_some(),
"undo restored the erased entity"
);
assert!(
host.app.tabs[0].scene.is_reference_only(handle),
"undo restored the designation together with its entity"
);
}
#[test]
fn user_paths_refuse_and_plugin_paths_write_reference_only_entities() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL));
host.set_reference_only(handle, true).unwrap();
let plain = host.add_entity(reference_test_line(10.0, 5.0, Handle::NULL));
// Grips: the reference-only entity contributes none; the editable one
// keeps its own.
host.set_selection(&[handle]).unwrap();
host.app.refresh_selected_grips();
assert!(
host.app.tabs[0].selected_grips.is_empty(),
"reference-only entities contribute no grips"
);
host.set_selection(&[plain]).unwrap();
host.app.refresh_selected_grips();
assert!(!host.app.tabs[0].selected_grips.is_empty());
// The user in-place update refuses; the user batch skips only the
// reference-only member; the plugin-owned variants write through.
assert!(!host.app.tabs[0]
.scene
.update_entity(reference_test_line(20.0, 0.0, handle)));
let applied = host.app.tabs[0].scene.update_entities(vec![
reference_test_line(20.0, 0.0, handle),
reference_test_line(20.0, 5.0, plain),
]);
assert_eq!(
applied, 1,
"the user batch applies only the editable entity"
);
let end_x = |scene: &crate::scene::Scene, h: Handle| match scene.document.get_entity(h) {
Some(EntityType::Line(line)) => line.end.x,
_ => panic!("entity {h:?} must be a line"),
};
let scene = &host.app.tabs[0].scene;
assert_eq!(
end_x(scene, handle),
10.0,
"the user paths refused the write"
);
assert_eq!(end_x(scene, plain), 20.0);
// The plugin-owned variant writes through. A real plugin refresh
// clones the live entity (designation included), edits it, and
// replaces it — so model that flow instead of a bare synthetic.
let mut moved = match host.app.tabs[0].scene.document.get_entity(handle) {
Some(entity) => entity.clone(),
_ => unreachable!("the entity exists"),
};
if let EntityType::Line(line) = &mut moved {
line.end.x = 30.0;
}
assert!(host.app.tabs[0].scene.update_entity_plugin(moved));
let scene = &host.app.tabs[0].scene;
assert_eq!(
end_x(scene, handle),
30.0,
"the plugin-owned update writes through"
);
assert!(
scene.is_reference_only(handle),
"a refresh-style plugin write preserves the designation"
);
// User erase skips reference-only entities; the plugin erase path
// removes them.
host.app.tabs[0].scene.erase_entities(&[handle]);
assert!(
host.app.tabs[0].scene.document.get_entity(handle).is_some(),
"user erase skips reference-only entities"
);
host.app.tabs[0].scene.erase_entities_plugin(&[handle]);
assert!(
host.app.tabs[0].scene.document.get_entity(handle).is_none(),
"the plugin erase path removes reference-only entities"
);
assert!(host.app.tabs[0].scene.document.get_entity(plain).is_some());
}
#[test]
fn select_all_visible_skips_reference_only_entities() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL));
host.set_reference_only(handle, true).unwrap();
let plain = host.add_entity(reference_test_line(10.0, 5.0, Handle::NULL));
assert_eq!(host.app.tabs[0].scene.select_all_visible(), 1);
let selected = host.app.tabs[0].scene.selected_handles_in_order();
assert_eq!(selected, vec![plain]);
}
#[test]
fn add_and_update_auto_register_novel_layers_with_real_handles() {
// A plugin adds/edits an entity naming a layer no LAYER command ever
+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.
pub(super) fn refresh_selected_grips(&mut self) {
let i = self.active_tab;
// Reference-only targets are cancelled like locked-layer ones: the
// plugin that owns the display geometry may have just designated the
// entity, and a drag that edits it would be overwritten on refresh.
let locked_active_grip = self.tabs[i].active_grip.as_ref().is_some_and(|grip| {
grip.targets
.iter()
.any(|target| self.tabs[i].scene.is_layer_locked(target.handle))
grip.targets.iter().any(|target| {
self.tabs[i].scene.is_layer_locked(target.handle)
|| self.tabs[i].scene.is_reference_only(target.handle)
})
});
if locked_active_grip {
self.cancel_active_grip_edit();
@@ -2682,7 +2686,8 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={}
let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle();
let selected = self.tabs[i].scene.selected_entities();
let single_handle = (selected.len() == 1
&& !self.tabs[i].scene.is_layer_locked(selected[0].0))
&& !self.tabs[i].scene.is_layer_locked(selected[0].0)
&& !self.tabs[i].scene.is_reference_only(selected[0].0))
.then(|| selected[0].0);
let mut grips = Vec::new();
let mut handles = Vec::new();
@@ -2695,7 +2700,9 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={}
selected
};
for (handle, entity) in selected {
if self.tabs[i].scene.is_layer_locked(handle) {
if self.tabs[i].scene.is_layer_locked(handle)
|| self.tabs[i].scene.is_reference_only(handle)
{
continue;
}
let contextual = crate::scene::annotative::entity_for_annotation_context(
+73 -10
View File
@@ -226,7 +226,7 @@ fn record(
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 mut value = record(
"entities",
@@ -245,6 +245,9 @@ fn entity_record(entity: &codec::EntityType) -> Result<Value, String> {
"record_type".into(),
json!(crate::entities::names::dxf_name(entity)),
);
// Scene-scoped designation (API v9): the set membership of this entity's
// handle, resolved by the caller from the live scene.
object.insert("reference_only".into(), json!(reference_only));
Ok(value)
}
@@ -305,12 +308,13 @@ fn map_records<T: Serialize>(
.collect()
}
fn collection_records(
document: &codec::CadDocument,
collection: &str,
) -> Result<Vec<Value>, String> {
fn collection_records(scene: &crate::scene::Scene, collection: &str) -> Result<Vec<Value>, String> {
let document = &scene.document;
match collection {
"entities" => document.entities().map(entity_record).collect(),
"entities" => document
.entities()
.map(|entity| entity_record(entity, scene.is_reference_only(entity.common().handle)))
.collect(),
"objects" => {
let mut objects: Vec<_> = document.objects.iter().collect();
objects.sort_by_key(|(handle, _)| handle.value());
@@ -1385,7 +1389,7 @@ impl OpenCADStudio {
}
let definitions = referenced_definitions(&root_type);
let runtime_schema = collection.and_then(|collection| {
collection_records(&self.tabs[self.active_tab].scene.document, collection)
collection_records(&self.tabs[self.active_tab].scene, collection)
.ok()?
.into_iter()
.find(|record| {
@@ -1429,20 +1433,20 @@ impl OpenCADStudio {
}
pub(super) fn record_query(&self, request: &Value) -> Value {
let document = &self.tabs[self.active_tab].scene.document;
let scene = &self.tabs[self.active_tab].scene;
let Some(collection) = request["collection"].as_str() else {
return self.record_capabilities();
};
let mut records = Vec::new();
if collection == "all" {
for (name, _) in COLLECTIONS {
match collection_records(document, name) {
match collection_records(scene, name) {
Ok(mut values) => records.append(&mut values),
Err(error) => return failure("serialization_failed", error),
}
}
} else {
match collection_records(document, collection) {
match collection_records(scene, collection) {
Ok(values) => records = values,
Err(error) => return failure("unknown_collection", error),
}
@@ -1506,6 +1510,12 @@ impl OpenCADStudio {
if self.tabs[i].scene.is_layer_locked(handle) {
return Err(failure("layer_locked", "entity is on a locked layer"));
}
if self.tabs[i].scene.is_reference_only(handle) {
return Err(failure(
"reference_only",
"entity is reference-only (plugin-owned display data)",
));
}
let source = self.tabs[i]
.scene
.document
@@ -1692,6 +1702,59 @@ mod tests {
app.automation_op(&request.to_string())
}
#[test]
fn entities_records_expose_the_reference_only_designation() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let handle = app.tabs[i].scene.add_entity(EntityType::Line(
codec::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
),
));
// The designation is plugin-mutated scene state: mark through the
// same host API a plugin uses, so the read surface reflects the
// live set rather than any stored marker.
fn mark(app: &mut OpenCADStudio, handle: Handle, on: bool) {
let i = app.active_tab;
crate::app::plugin_host::HostSession::new(app, i)
.set_reference_only(handle, on)
.expect("mark through the plugin host API");
}
// The read surface reports the designation alongside the record.
mark(&mut app, handle, true);
let records = app.record_query(&json!({"collection":"entities"}));
assert_eq!(records["count"], 1);
assert_eq!(
records["records"][0]["handle"],
json!(format!("{:X}", handle.value()))
);
assert_eq!(records["records"][0]["reference_only"], json!(true));
// The MCP write path treats a reference-only entity like a locked
// layer: the edit is refused, honestly.
let refused = execute(
&mut app,
json!({
"op":"set_properties",
"collection":"entities",
"handle": format!("{:X}", handle.value()),
"updates":[{"path":"/end/x","value":20.0}],
}),
"reference-only",
);
assert_eq!(refused["ok"], json!(false));
assert_eq!(refused["code"], json!("reference_only"));
// Clearing the designation is reported too.
mark(&mut app, handle, false);
let records = app.record_query(&json!({"collection":"entities"}));
assert_eq!(records["records"][0]["reference_only"], json!(false));
}
#[test]
fn records_query_and_edit_every_mutable_record_family() {
let mut app = OpenCADStudio::new_for_test();
+4
View File
@@ -2276,6 +2276,10 @@ impl OpenCADStudio {
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
}
self.opening.take();
// The document and its derived caches have settled: mark the tab's
// generation so the next message boundary announces `DocumentOpened`
// to native plugins exactly once for this load (API v9).
self.tabs[i].note_document_loaded();
// The tab list just changed (and the active tab with it): remember
// it for the next boot's restore flow (web session memory).
#[cfg(target_arch = "wasm32")]
+38
View File
@@ -312,6 +312,11 @@ impl OpenCADStudio {
self.last_plugin_selection = Some(key);
let handles = self.tabs[i].scene.selected_handles_in_order();
crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles.clone());
// Same guarded boundary, second surface (API v9): the plain
// `SelectionChanged` reaches every loaded native plugin through
// `BuiltinPlugin::on_notification`, without a shared-view
// subscription.
crate::plugin::v4_support::publish_selection_changed(handles.clone());
self.control_record_selection_event(&handles);
}
@@ -331,6 +336,35 @@ impl OpenCADStudio {
self.last_plugin_document = Some(key);
crate::plugin::v4_support::publish_drawing_changed(tab.id, tab.scene.geometry_epoch);
crate::plugin::v4_support::publish_document_view_v4(tab.id, &tab.scene.document);
// Same guarded boundary, second surface (API v9): the plain
// `DocumentChanged` carries the settled geometry epoch to every
// loaded native plugin through `BuiltinPlugin::on_notification`.
crate::plugin::v4_support::publish_document_changed(tab.scene.geometry_epoch);
}
/// Announce that a whole document became resident in the active tab —
/// opened from file, created from a template, or loaded by a system open
/// (API v9). Keyed on `(tab id, document generation)`, which changes only
/// when a tab's content is wholesale replaced, so an edit can never fire
/// it twice and a reload fires it exactly once. Part of the same
/// post-message boundary as the selection and document dispatches: runs
/// after the mutation settles and pending history scopes closed, never
/// inside history replay or interactive picking.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn notify_plugins_document_opened(&mut self) {
if self.active_tab >= self.tabs.len() {
return;
}
let i = self.active_tab;
if self.tabs[i].is_start {
return;
}
let key = (self.tabs[i].id, self.tabs[i].document_generation);
if self.last_plugin_document_opened == Some(key) {
return;
}
self.last_plugin_document_opened = Some(key);
crate::plugin::v4_support::publish_document_opened(self.tabs[i].id);
}
pub fn update(&mut self, msg: Message) -> Task<Message> {
@@ -577,6 +611,10 @@ impl OpenCADStudio {
self.notify_plugins_selection_changed();
#[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_document_changed();
// A message may also have installed a whole document into a tab (file
// open, template new); announce it on the same boundary (API v9).
#[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_document_opened();
// OTRACK acquires tracking points only while a command or grip drag is
// running; drop them once neither is active so the temporary tracking
// points / vectors disappear when the command ends (issue #64). A grip
+4
View File
@@ -736,6 +736,9 @@ impl OpenCADStudio {
bounds,
candidate_handles.as_ref(),
));
// Reference-only entities (API v9) are plugin-owned display geometry:
// a box/window selection must not pick them.
handles.retain(|h| !scene.is_reference_only(*h));
timing.hit = elapsed(t_hit);
(handles, timing)
}
@@ -5308,6 +5311,7 @@ properties={:.1}ms picked={}",
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
.filter(|&h| self.tabs[i].scene.passes_selection_filter(h))
.filter(|&h| !self.tabs[i].scene.is_reference_only(h))
.collect();
if cands.len() >= 2 {
// Overlap: open the list box at the cursor.