forked from 1b-agent/opencadstudio-upstream
Merge pull request #1747 from schoeller/reference_only_entities
Reference-only entities (plugin API v9): a scene-scoped, in-memory designation for plugin-owned display geometry, plus HostNotification dispatch to native plugins so they can re-mark on DocumentOpened. Resolves the new DeltaSnapshot site in block_editor.rs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
68330ce39e
40 files changed
+1977
-85
No files matched your search
@@ -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};
|
||||
|
||||
|
||||
@@ -223,6 +223,7 @@ impl OpenCADStudio {
|
||||
active_layer,
|
||||
structure,
|
||||
parametric_constraints,
|
||||
reference_designations: Vec::new(),
|
||||
named_parameters,
|
||||
label: format!("Edit block {}", session.block_name),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -3598,6 +3598,7 @@ fn apply_dimension_jog(
|
||||
}
|
||||
|
||||
pub trait DimensionTess {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tessellate(
|
||||
&self,
|
||||
document: &CadDocument,
|
||||
@@ -3611,10 +3612,12 @@ pub trait DimensionTess {
|
||||
bg_color: [f32; 4],
|
||||
view_aabb: Option<[f32; 4]>,
|
||||
world_per_pixel: Option<f32>,
|
||||
reference_only: &rustc_hash::FxHashSet<codec::Handle>,
|
||||
) -> Vec<WireModel>;
|
||||
}
|
||||
|
||||
impl DimensionTess for Dimension {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tessellate(
|
||||
&self,
|
||||
document: &CadDocument,
|
||||
@@ -3628,6 +3631,7 @@ impl DimensionTess for Dimension {
|
||||
bg_color: [f32; 4],
|
||||
view_aabb: Option<[f32; 4]>,
|
||||
world_per_pixel: Option<f32>,
|
||||
reference_only: &rustc_hash::FxHashSet<codec::Handle>,
|
||||
) -> Vec<WireModel> {
|
||||
tessellate_dimension_inner(
|
||||
document,
|
||||
@@ -3642,10 +3646,12 @@ impl DimensionTess for Dimension {
|
||||
bg_color,
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
reference_only,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tessellate_dimension_inner(
|
||||
document: &CadDocument,
|
||||
handle: Handle,
|
||||
@@ -3662,6 +3668,7 @@ fn tessellate_dimension_inner(
|
||||
bg_color: [f32; 4],
|
||||
view_aabb: Option<[f32; 4]>,
|
||||
world_per_pixel: Option<f32>,
|
||||
reference_only: &rustc_hash::FxHashSet<codec::Handle>,
|
||||
) -> Vec<WireModel> {
|
||||
if let Dimension::Arc(arc) = dim {
|
||||
if let Some((local_arc, normal)) = arc_dimension_in_ocs(arc) {
|
||||
@@ -3678,6 +3685,7 @@ fn tessellate_dimension_inner(
|
||||
bg_color,
|
||||
None,
|
||||
world_per_pixel,
|
||||
reference_only,
|
||||
);
|
||||
for wire in &mut wires {
|
||||
map_wire_ocs_to_wcs(wire, normal);
|
||||
@@ -3700,6 +3708,7 @@ fn tessellate_dimension_inner(
|
||||
bg_color,
|
||||
None,
|
||||
world_per_pixel,
|
||||
reference_only,
|
||||
);
|
||||
for wire in &mut wires {
|
||||
map_wire_ocs_to_wcs(wire, normal);
|
||||
@@ -3721,6 +3730,7 @@ fn tessellate_dimension_inner(
|
||||
bg_color,
|
||||
None,
|
||||
world_per_pixel,
|
||||
reference_only,
|
||||
);
|
||||
for wire in &mut wires {
|
||||
map_wire_ocs_to_wcs(wire, normal);
|
||||
@@ -4420,6 +4430,7 @@ fn tessellate_dimension_inner(
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
text_color,
|
||||
reference_only,
|
||||
);
|
||||
for mut w in text_wires {
|
||||
w.name = name.clone();
|
||||
@@ -4437,6 +4448,7 @@ fn tessellate_dimension_inner(
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
text_color,
|
||||
reference_only,
|
||||
);
|
||||
for mut w in tol_wires {
|
||||
w.name = name.clone();
|
||||
@@ -8890,6 +8902,7 @@ mod linear_transform_tests {
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
None,
|
||||
None,
|
||||
&rustc_hash::FxHashSet::default(),
|
||||
);
|
||||
let mut points = Vec::new();
|
||||
for wire in &wires {
|
||||
@@ -9221,6 +9234,7 @@ mod layout_parity_tests {
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
None,
|
||||
None,
|
||||
&rustc_hash::FxHashSet::default(),
|
||||
);
|
||||
let mut points = Vec::new();
|
||||
let mut fills = 0;
|
||||
|
||||
@@ -2344,6 +2344,7 @@ impl MultiLeaderTess for MultiLeader {
|
||||
depth_override: None,
|
||||
display_visible: true,
|
||||
snap_only: false,
|
||||
reference_only: false,
|
||||
plot_visible: true,
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
|
||||
@@ -48,6 +48,31 @@ pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec<codec::Handle>) {
|
||||
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.
|
||||
pub fn publish_command_state_changed(tab_id: u64, command: Option<String>) {
|
||||
broadcast(HostNotification::CommandStateChanged { tab_id, command });
|
||||
@@ -71,7 +96,58 @@ pub fn notification_handler() -> NotificationHandler {
|
||||
}
|
||||
|
||||
fn broadcast(notification: HostNotification) {
|
||||
#[cfg(test)]
|
||||
record_test_dispatch(¬ification);
|
||||
crate::plugin::external::with_manager(|mgr| {
|
||||
mgr.broadcast_notification(None, notification);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Test-only dispatch observation ──────────────────────────────────────────
|
||||
//
|
||||
// App-level tests assert boundary dispatch behaviour through this per-thread
|
||||
// log: `cargo test` runs each test on its own thread, so a thread-local log
|
||||
// records exactly the notifications this test's app instance broadcast,
|
||||
// without cross-test interference. One recorded entry stands for one fan-out
|
||||
// to every loaded native plugin (the manager's broadcast), so counts here are
|
||||
// counts per plugin.
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_DISPATCH_LOG: std::cell::RefCell<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()))
|
||||
}
|
||||
Vendored
+5
@@ -792,6 +792,9 @@ fn tessellate_sub_local(
|
||||
// top-level entities, applied per-defn.
|
||||
let wires_out = if let EntityType::Dimension(dimension) = sub {
|
||||
use crate::entities::dimension::DimensionTess;
|
||||
// Block-definition members never carry the reference-only
|
||||
// designation (it targets top-level geometry — see the API v9
|
||||
// known-limit note), so the block cache tessellates with an empty set.
|
||||
dimension.tessellate(
|
||||
doc,
|
||||
h,
|
||||
@@ -804,6 +807,7 @@ fn tessellate_sub_local(
|
||||
bg_color,
|
||||
None,
|
||||
None,
|
||||
&HashSet::default(),
|
||||
)
|
||||
} else {
|
||||
tessellate::tessellate(
|
||||
@@ -1840,6 +1844,7 @@ impl Batches {
|
||||
depth_override: b.local_depth,
|
||||
display_visible: !b.hide_unselected || selected,
|
||||
snap_only: false,
|
||||
reference_only: false,
|
||||
plot_visible: b.plot_visible,
|
||||
fill_is_3d: b.fill_is_3d,
|
||||
fill_is_2d_solid: b.fill_is_2d_solid,
|
||||
|
||||
@@ -473,6 +473,7 @@ fn section_symbol_wires(
|
||||
/// so it picks up the standard text LOD ladder (baseline / greek / full),
|
||||
/// then re-color the returned wires with the dimension's resolved text colour
|
||||
/// (so DIMCLRT / DIMSTYLE colours win over the synthetic Text's defaults).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn tessellate_entity_dim_text(
|
||||
document: &codec::CadDocument,
|
||||
selected: &HashSet<Handle>,
|
||||
@@ -483,6 +484,7 @@ pub(crate) fn tessellate_entity_dim_text(
|
||||
view_aabb: Option<[f32; 4]>,
|
||||
world_per_pixel: Option<f32>,
|
||||
text_color: [f32; 4],
|
||||
reference_only: &HashSet<Handle>,
|
||||
) -> Vec<WireModel> {
|
||||
let mut wires = tessellate_entity(
|
||||
document,
|
||||
@@ -496,6 +498,7 @@ pub(crate) fn tessellate_entity_dim_text(
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
false,
|
||||
reference_only,
|
||||
);
|
||||
for w in &mut wires {
|
||||
// Synth dim text carries no real entity colour — paint everything
|
||||
@@ -508,6 +511,7 @@ pub(crate) fn tessellate_entity_dim_text(
|
||||
}
|
||||
wires
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn tessellate_entity(
|
||||
document: &codec::CadDocument,
|
||||
selected: &HashSet<Handle>,
|
||||
@@ -520,6 +524,7 @@ pub(crate) fn tessellate_entity(
|
||||
view_aabb: Option<[f32; 4]>,
|
||||
world_per_pixel: Option<f32>,
|
||||
paper_space: bool,
|
||||
reference_only: &HashSet<Handle>,
|
||||
) -> Vec<WireModel> {
|
||||
let mut wires = tessellate_entity_inner(
|
||||
document,
|
||||
@@ -533,6 +538,7 @@ pub(crate) fn tessellate_entity(
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
paper_space,
|
||||
reference_only,
|
||||
);
|
||||
|
||||
let layer_plottable = document
|
||||
@@ -547,8 +553,21 @@ pub(crate) fn tessellate_entity(
|
||||
}
|
||||
}
|
||||
|
||||
// Reference-only entities (API v9) render and snap normally but must not
|
||||
// be picked by the built-in edit paths; the pick-side wire filters in
|
||||
// `scene::pick::hit_test` and the selection-gather retains consume the
|
||||
// flag. The stamp is driven by the scene's in-memory designation set —
|
||||
// outside `Scene` this stays pure data, so the parallel tessellation
|
||||
// workers only need the handle set, not scene access.
|
||||
if reference_only.contains(&e.common().handle) {
|
||||
for wire in &mut wires {
|
||||
wire.reference_only = true;
|
||||
}
|
||||
}
|
||||
|
||||
wires
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tessellate_entity_inner(
|
||||
document: &codec::CadDocument,
|
||||
selected: &HashSet<Handle>,
|
||||
@@ -567,6 +586,10 @@ fn tessellate_entity_inner(
|
||||
// Retained through recursive INSERT expansion; PSLTSCALE itself is applied
|
||||
// by the viewport's GPU uniform so it never changes resident wire content.
|
||||
_paper_space: bool,
|
||||
// The scene's reference-only designation set (API v9): drives the
|
||||
// `WireModel::reference_only` stamp for dimensions' synthesis paths that
|
||||
// bypass the wrapper's post-stamp.
|
||||
reference_only: &HashSet<Handle>,
|
||||
) -> Vec<WireModel> {
|
||||
let contextual = crate::scene::annotative::entity_for_annotation_context(
|
||||
document,
|
||||
@@ -1019,6 +1042,7 @@ fn tessellate_entity_inner(
|
||||
bg_color,
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
reference_only,
|
||||
);
|
||||
for w in &mut wires {
|
||||
w.aci = aci;
|
||||
|
||||
@@ -1808,6 +1808,7 @@ pub fn tessellate(
|
||||
depth_override: None,
|
||||
display_visible: true,
|
||||
snap_only: false,
|
||||
reference_only: false,
|
||||
plot_visible: true,
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
@@ -1974,6 +1975,7 @@ pub fn tessellate(
|
||||
depth_override: None,
|
||||
display_visible: true,
|
||||
snap_only: false,
|
||||
reference_only: false,
|
||||
plot_visible: true,
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
|
||||
@@ -2279,7 +2279,7 @@ mod tests {
|
||||
scene.sync_diameter_association_angle(dimension);
|
||||
|
||||
let recording = scene.take_undo_recording().expect("undo recording");
|
||||
let (_, objects, _, _) = recording.into_recorded_images();
|
||||
let (_, objects, _, _, _) = recording.into_recorded_images();
|
||||
assert!(objects
|
||||
.iter()
|
||||
.any(|(handle, before)| *handle == association && before.is_some()));
|
||||
|
||||
+31
-7
@@ -519,23 +519,47 @@ impl Scene {
|
||||
/// existing entity.
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub fn update_entity(&mut self, entity: EntityType) -> bool {
|
||||
let mut changed = Vec::with_capacity(1);
|
||||
let mut block_change = false;
|
||||
if !self.update_entity_impl(entity, &mut changed, &mut block_change) {
|
||||
return false;
|
||||
}
|
||||
self.publish_entity_updates(changed, block_change);
|
||||
true
|
||||
self.update_entities_policy(vec![entity], false) == 1
|
||||
}
|
||||
|
||||
/// Batched variant of [`update_entity`]: writes every replacement, then
|
||||
/// issues a single geometry bump for the whole set instead of one bump per
|
||||
/// entity.
|
||||
pub fn update_entities(&mut self, updates: Vec<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 changed = Vec::with_capacity(updates.len());
|
||||
let mut block_change = false;
|
||||
for entity in updates {
|
||||
if !allow_reference_only && self.is_reference_only(entity.common().handle) {
|
||||
continue;
|
||||
}
|
||||
if self.update_entity_impl(entity, &mut changed, &mut block_change) {
|
||||
moved += 1;
|
||||
}
|
||||
|
||||
+98
-4
@@ -36,6 +36,8 @@ pub enum BlockScalePolicy {
|
||||
mod block_base_point_tests;
|
||||
mod boundary;
|
||||
mod camera_ops;
|
||||
#[cfg(test)]
|
||||
mod reference_only_tests;
|
||||
pub use camera_ops::{add_background, copy_background, erase_background};
|
||||
pub(crate) mod centerline;
|
||||
pub(crate) mod centermark;
|
||||
@@ -61,6 +63,7 @@ mod page_setup;
|
||||
mod paper;
|
||||
mod preview;
|
||||
mod project;
|
||||
pub mod reference_only;
|
||||
mod scene_markers;
|
||||
mod selection;
|
||||
pub mod viewport_dimension_pick;
|
||||
@@ -84,6 +87,10 @@ pub(crate) use convert::tess::{
|
||||
|
||||
/// Keep construction-history curves in the same per-entity tessellation memo
|
||||
/// as their parent, so navigation never rebuilds them on every frame.
|
||||
///
|
||||
/// `reference_only` is the scene's designation set: its membership decides
|
||||
/// whether the produced wires carry the `WireModel::reference_only` click-
|
||||
/// through stamp (see `scene::reference_only`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn tessellate_entity(
|
||||
document: &CadDocument,
|
||||
@@ -97,6 +104,7 @@ pub(crate) fn tessellate_entity(
|
||||
view_aabb: Option<[f32; 4]>,
|
||||
world_per_pixel: Option<f32>,
|
||||
paper_space: bool,
|
||||
reference_only: &HashSet<Handle>,
|
||||
) -> Vec<WireModel> {
|
||||
let mut wires = convert::tess::tessellate_entity(
|
||||
document,
|
||||
@@ -110,6 +118,7 @@ pub(crate) fn tessellate_entity(
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
paper_space,
|
||||
reference_only,
|
||||
);
|
||||
if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) {
|
||||
let handle = entity.common().handle;
|
||||
@@ -161,6 +170,7 @@ pub(crate) fn tessellate_entity(
|
||||
view_aabb,
|
||||
world_per_pixel,
|
||||
paper_space,
|
||||
reference_only,
|
||||
)
|
||||
};
|
||||
for wire in &mut construction {
|
||||
@@ -760,6 +770,13 @@ pub struct UndoRecording {
|
||||
>,
|
||||
parametric_constraints_order: Vec<parametric_constraints::ParametricScope>,
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -775,6 +792,7 @@ impl UndoRecording {
|
||||
&& self.object_order.is_empty()
|
||||
&& self.parametric_constraints_order.is_empty()
|
||||
&& self.named_parameters_before.is_none()
|
||||
&& self.reference_designations_before.is_empty()
|
||||
}
|
||||
|
||||
/// Consume the recording directories in deterministic first-touch
|
||||
@@ -792,6 +810,7 @@ impl UndoRecording {
|
||||
parametric_constraints::ParametricConstraintSet,
|
||||
)>,
|
||||
Option<named_parameters::ParameterTable>,
|
||||
Vec<(Handle, bool)>,
|
||||
) {
|
||||
let entities = self
|
||||
.order
|
||||
@@ -812,11 +831,13 @@ impl UndoRecording {
|
||||
.map(|before| (scope, before))
|
||||
})
|
||||
.collect();
|
||||
let reference_designations = std::mem::take(&mut self.reference_designations_before);
|
||||
(
|
||||
entities,
|
||||
objects,
|
||||
parametric_constraints,
|
||||
self.named_parameters_before,
|
||||
reference_designations,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2138,6 +2159,11 @@ pub struct Scene {
|
||||
pub selected_constraint: Option<crate::scene::parametric_constraints::ConstraintId>,
|
||||
/// Session-only ISOLATEOBJECTS / HIDEOBJECTS state. Never written to DWG/DXF.
|
||||
pub object_isolation: ObjectIsolationState,
|
||||
/// Scene-scoped reference-only designations (API v9): plugin-owned
|
||||
/// display geometry the built-in edit paths refuse. In-memory only,
|
||||
/// keyed by handle, lives and dies with this scene — never written to
|
||||
/// DWG/DXF (see `scene::reference_only`).
|
||||
pub(crate) reference_only: reference_only::ReferenceOnlyDesignation,
|
||||
/// Entity handles temporarily removed from the base render while an
|
||||
/// interactive grip preview draws their live replacement.
|
||||
/// Separate from object isolation so a grip can never activate the
|
||||
@@ -2782,6 +2808,7 @@ impl Scene {
|
||||
selected_order: Vec::new(),
|
||||
selected_constraint: None,
|
||||
object_isolation: ObjectIsolationState::default(),
|
||||
reference_only: Default::default(),
|
||||
preview_hidden: HashSet::default(),
|
||||
command_preview_hidden: HashSet::default(),
|
||||
refedit_keep: None,
|
||||
@@ -3434,6 +3461,26 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a reference-only designation's value before its first change
|
||||
/// within the open delta recording (API v9). The designation set lives on
|
||||
/// `Scene`, outside the document, so it needs its own channel — like
|
||||
/// the parametric-constraint directory above — for undo to restore the
|
||||
/// designation together with the entity it belongs to. First touch wins.
|
||||
pub(crate) fn record_undo_reference_designation_before(&mut self, handle: Handle) {
|
||||
if let Some(recording) = self.undo_recording.as_mut() {
|
||||
if !recording
|
||||
.reference_designations_before
|
||||
.iter()
|
||||
.any(|(recorded, _)| *recorded == handle)
|
||||
{
|
||||
let before = self.reference_only.is_marked(handle);
|
||||
recording
|
||||
.reference_designations_before
|
||||
.push((handle, before));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Flag the open recording as touching non-entity state (a new layer, a
|
||||
/// group edit, a `*D` block record) that a pure-entity delta cannot
|
||||
/// restore. The app's per-command predicate keeps this from firing.
|
||||
@@ -3491,6 +3538,13 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
|
||||
// History replay can apply an entity removal the designation channels
|
||||
// predate (an "added" side rolled back). Sweep so the set never keeps
|
||||
// an entry for an absent entity; exact designations come right after
|
||||
// this from the delta's own channel.
|
||||
if !self.reference_only.is_empty() {
|
||||
self.reference_only.retain_present(&self.document);
|
||||
}
|
||||
if !changes.is_empty() {
|
||||
self.invalidate_dependency_index();
|
||||
}
|
||||
@@ -7555,6 +7609,7 @@ impl Scene {
|
||||
None,
|
||||
None,
|
||||
anno_scale_override.is_some(),
|
||||
self.reference_only.handles(),
|
||||
);
|
||||
// Solid B-rep corners snap via the mesh edge list (their wire
|
||||
// path is an empty shell). Enrich before the memo/assembly split
|
||||
@@ -9472,6 +9527,22 @@ impl Scene {
|
||||
.is_some_and(|keep| !keep.contains(&handle)))
|
||||
}
|
||||
|
||||
/// True when `handle` addresses a reference-only entity (API v9):
|
||||
/// plugin-owned display geometry the built-in edit paths treat like a
|
||||
/// locked-layer entity while its owning plugin keeps write access through
|
||||
/// the plugin host API. Absent entities are never reference-only.
|
||||
pub fn is_reference_only(&self, handle: Handle) -> bool {
|
||||
self.reference_only.is_marked(handle) && self.document.get_entity(handle).is_some()
|
||||
}
|
||||
|
||||
/// Insert or remove `handle` in the scene-scoped designation set,
|
||||
/// returning whether membership changed. Pure storage: no epoch bump, no
|
||||
/// undo bookkeeping — those belong to the callers (the plugin host API
|
||||
/// mutation and the history channels).
|
||||
pub(crate) fn set_reference_only_membership(&mut self, handle: Handle, on: bool) -> bool {
|
||||
self.reference_only.set(handle, on)
|
||||
}
|
||||
|
||||
/// The name of the locked layer `handle` sits on, if any (for messages).
|
||||
pub fn locked_layer_name(&self, handle: Handle) -> Option<String> {
|
||||
let name = self
|
||||
@@ -10763,6 +10834,21 @@ impl Scene {
|
||||
pub fn interaction_candidate_handles(
|
||||
&self,
|
||||
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>> {
|
||||
let mut handles: HashSet<Handle> = candidates
|
||||
.iter()
|
||||
@@ -11008,7 +11094,7 @@ impl Scene {
|
||||
candidate_handles.as_ref(),
|
||||
));
|
||||
}
|
||||
handles.retain(|&h| self.passes_selection_filter(h));
|
||||
handles.retain(|&h| self.passes_selection_filter(h) && !self.is_reference_only(h));
|
||||
handles
|
||||
}
|
||||
|
||||
@@ -11684,6 +11770,9 @@ impl Scene {
|
||||
// `world_offset` subtraction even though `current_layout != "Model"`.
|
||||
// Decide based on the block being tessellated, not the layout.
|
||||
let is_model_block = block_handle == self.model_space_block_handle();
|
||||
// The reference-only designation set drives the wire stamp; captured
|
||||
// as a shared ref like `doc`/`sel` so the rayon workers can read it.
|
||||
let reference_only_set = self.reference_only.handles();
|
||||
let bg = if self.current_layout == "Model" {
|
||||
self.bg_color
|
||||
} else {
|
||||
@@ -11800,6 +11889,7 @@ impl Scene {
|
||||
view_aabb,
|
||||
wpp,
|
||||
paper,
|
||||
reference_only_set,
|
||||
);
|
||||
(e.common().handle, Arc::new(w))
|
||||
})
|
||||
@@ -11846,6 +11936,7 @@ impl Scene {
|
||||
view_aabb,
|
||||
wpp,
|
||||
paper,
|
||||
reference_only_set,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -11880,6 +11971,7 @@ impl Scene {
|
||||
view_aabb,
|
||||
wpp,
|
||||
paper,
|
||||
reference_only_set,
|
||||
)),
|
||||
Ok(None) => Some(Vec::new()),
|
||||
Err(()) => None,
|
||||
@@ -12900,6 +12992,7 @@ vis_index={:.1} visible_probe={:.1}",
|
||||
None,
|
||||
None,
|
||||
paper,
|
||||
self.reference_only.handles(),
|
||||
);
|
||||
// Same solid-corner snaps as the resident paths, so one-off consumers
|
||||
// (overlay changed-wires, edit previews) agree with hit-test/snap.
|
||||
@@ -13220,7 +13313,8 @@ pub(crate) fn drop_layout_viewports(document: &mut codec::CadDocument, block: Ha
|
||||
}
|
||||
|
||||
/// LAYOUTCREATEVIEWPORT: a layout opened for the first time gets a viewport.
|
||||
static LAYOUT_CREATE_VIEWPORT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
static LAYOUT_CREATE_VIEWPORT: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(true);
|
||||
|
||||
pub fn layout_create_viewport() -> bool {
|
||||
LAYOUT_CREATE_VIEWPORT.load(std::sync::atomic::Ordering::Relaxed)
|
||||
@@ -14507,7 +14601,7 @@ mod delta_undo_tests {
|
||||
let handle = scene.add_entity(EntityType::RasterImage(image));
|
||||
let rec = scene.take_undo_recording().unwrap();
|
||||
assert!(!rec.is_poisoned());
|
||||
let (entities, objects, _parametric_constraints, _named_parameters) =
|
||||
let (entities, objects, _parametric_constraints, _named_parameters, _designations) =
|
||||
rec.into_recorded_images();
|
||||
assert_eq!(entities.len(), 1);
|
||||
assert_eq!(entities[0].0, handle);
|
||||
@@ -14532,7 +14626,7 @@ mod delta_undo_tests {
|
||||
scene.erase_entities(&[h1]);
|
||||
let rec = scene.take_undo_recording().unwrap();
|
||||
assert!(!rec.is_poisoned());
|
||||
let (entities, objects, _parametric_constraints, _named_parameters) =
|
||||
let (entities, objects, _parametric_constraints, _named_parameters, _designations) =
|
||||
rec.into_recorded_images();
|
||||
assert_eq!(entities.len(), 1);
|
||||
assert_eq!(entities[0].0, h1);
|
||||
|
||||
@@ -822,6 +822,11 @@ pub struct WireModel {
|
||||
/// (`display_visible` false) it is what object snaps find; drawn, it is
|
||||
/// display only and snaps skip it too ([`Self::is_display_only`]).
|
||||
pub snap_only: bool,
|
||||
/// Plugin-owned reference-only source (API v9): the entity is display
|
||||
/// geometry the built-in edit paths must not pick — clicks fall through
|
||||
/// to the editable entity underneath. Rendering and snapping are
|
||||
/// unaffected.
|
||||
pub reference_only: bool,
|
||||
/// Whether this wire is included in plotted output.
|
||||
pub plot_visible: bool,
|
||||
/// `true` when [`fill_tris`] is a real 3-D surface (PolyfaceMesh /
|
||||
@@ -910,6 +915,7 @@ impl WireModel {
|
||||
depth_override: None,
|
||||
display_visible: true,
|
||||
snap_only: false,
|
||||
reference_only: false,
|
||||
plot_visible: true,
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
@@ -1448,6 +1454,7 @@ impl Default for WireModel {
|
||||
depth_override: None,
|
||||
display_visible: true,
|
||||
snap_only: false,
|
||||
reference_only: false,
|
||||
plot_visible: true,
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
|
||||
@@ -4502,7 +4502,7 @@ mod tests {
|
||||
.take_undo_recording()
|
||||
.expect("an undo recording should still be open");
|
||||
|
||||
let (entities, _objects, _parametric_constraints, _named_parameters) =
|
||||
let (entities, _objects, _parametric_constraints, _named_parameters, _designations) =
|
||||
recording.into_recorded_images();
|
||||
let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect();
|
||||
assert!(
|
||||
@@ -4559,7 +4559,7 @@ mod tests {
|
||||
let recording = scene
|
||||
.take_undo_recording()
|
||||
.expect("an undo recording should still be open");
|
||||
let (_entities, _objects, parametric_constraints, _named_parameters) =
|
||||
let (_entities, _objects, parametric_constraints, _named_parameters, _designations) =
|
||||
recording.into_recorded_images();
|
||||
assert_eq!(
|
||||
parametric_constraints.len(),
|
||||
|
||||
+50
-35
@@ -225,7 +225,10 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
|
||||
let mut best_dist = f32::MAX;
|
||||
let mut best: Option<&str> = None;
|
||||
|
||||
for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) {
|
||||
for wire in wires
|
||||
.iter()
|
||||
.filter(|wire| wire.point_marker.is_some() && !wire.reference_only)
|
||||
{
|
||||
let tolerance = pick_tolerance_px(wire, lw_display, base_radius_px);
|
||||
let mut previous = None;
|
||||
for (index, point) in wire.points.iter().enumerate() {
|
||||
@@ -266,7 +269,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(segment.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.point_marker.is_some() {
|
||||
@@ -299,7 +302,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
|
||||
} else {
|
||||
// Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain.
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.point_marker.is_some() {
|
||||
@@ -354,7 +357,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(triangle.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if let Some(depth) = triangle_ref_hit_depth(
|
||||
@@ -373,7 +376,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
|
||||
}
|
||||
} else {
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.fill_tris.is_empty() {
|
||||
@@ -412,7 +415,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(triangle.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if let Some(depth) = triangle_ref_hit_depth(
|
||||
@@ -431,7 +434,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
|
||||
}
|
||||
} else {
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.pick_tris.is_empty() {
|
||||
@@ -475,7 +478,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(glyph.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
let start = glyph.start as usize;
|
||||
@@ -491,7 +494,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
|
||||
}
|
||||
} else {
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.text_verts.is_empty() {
|
||||
@@ -526,7 +529,10 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
|
||||
}
|
||||
let mut hits: Vec<(f32, &str)> = Vec::new();
|
||||
let mut marker_hits: HashMap<&str, f32> = HashMap::default();
|
||||
for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) {
|
||||
for wire in wires
|
||||
.iter()
|
||||
.filter(|wire| wire.point_marker.is_some() && !wire.reference_only)
|
||||
{
|
||||
let tolerance = pick_tolerance_px(wire, lw_display, base_radius_px);
|
||||
let mut previous = None;
|
||||
for (index, point) in wire.points.iter().enumerate() {
|
||||
@@ -563,7 +569,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(segment.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.point_marker.is_some() {
|
||||
@@ -600,7 +606,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
|
||||
}));
|
||||
} else {
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.point_marker.is_some() {
|
||||
@@ -646,7 +652,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(triangle.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if hits.iter().any(|&(_, name)| name == wire.name) {
|
||||
@@ -670,7 +676,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
|
||||
}
|
||||
} else {
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.fill_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) {
|
||||
@@ -706,7 +712,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(triangle.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if hits.iter().any(|&(_, name)| name == wire.name) {
|
||||
@@ -730,7 +736,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
|
||||
}
|
||||
} else {
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.pick_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) {
|
||||
@@ -762,7 +768,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(glyph.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if hits.iter().any(|&(_, name)| name == wire.name) {
|
||||
@@ -780,7 +786,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>(
|
||||
}
|
||||
} else {
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.text_verts.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) {
|
||||
@@ -1324,7 +1330,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
// path below run identical code.
|
||||
let hit_name = |segment: &SegmentRef| -> Option<&'a str> {
|
||||
let wire = wires.source_wire(segment.wire)?;
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
return None;
|
||||
}
|
||||
let start = segment.start as usize;
|
||||
@@ -1369,7 +1375,10 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
}
|
||||
}
|
||||
}
|
||||
for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) {
|
||||
for wire in wires
|
||||
.iter()
|
||||
.filter(|wire| wire.point_marker.is_some() && !wire.reference_only)
|
||||
{
|
||||
if !seen.contains(wire.name.as_str())
|
||||
&& marker_segment_hit(wire, view_rot, eye, bounds, |a, b| segment_hits(a, b))
|
||||
&& seen.insert(wire.name.as_str())
|
||||
@@ -1385,7 +1394,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(triangle.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if already(&wire_hit, triangle.wire) {
|
||||
@@ -1412,7 +1421,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(glyph.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if already(&wire_hit, glyph.wire) {
|
||||
@@ -1435,7 +1444,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
|
||||
// Degenerate point-only wires have no indexed segment or surface primitive.
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.points.len() >= 2
|
||||
@@ -1494,7 +1503,7 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(segment.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
let start = segment.start as usize;
|
||||
@@ -1517,7 +1526,10 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>(
|
||||
out.push(wire.name.as_str());
|
||||
}
|
||||
}
|
||||
for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) {
|
||||
for wire in wires
|
||||
.iter()
|
||||
.filter(|wire| wire.point_marker.is_some() && !wire.reference_only)
|
||||
{
|
||||
if !seen.contains(wire.name.as_str())
|
||||
&& marker_segment_hit(wire, view_rot, eye, bounds, |a, b| cuts(a, b))
|
||||
&& seen.insert(wire.name.as_str())
|
||||
@@ -1528,7 +1540,7 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>(
|
||||
return out;
|
||||
}
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.points.len() < 2 {
|
||||
@@ -1569,7 +1581,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(segment.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
let start = segment.start as usize;
|
||||
@@ -1596,7 +1608,10 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
out.push(wire.name.as_str());
|
||||
}
|
||||
}
|
||||
for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) {
|
||||
for wire in wires
|
||||
.iter()
|
||||
.filter(|wire| wire.point_marker.is_some() && !wire.reference_only)
|
||||
{
|
||||
if !seen.contains(wire.name.as_str())
|
||||
&& marker_segment_hit(wire, view_rot, eye, bounds, |a, b| {
|
||||
point_in_polygon(a, poly)
|
||||
@@ -1616,7 +1631,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(triangle.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if seen.contains(wire.name.as_str()) {
|
||||
@@ -1641,7 +1656,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
let Some(wire) = wires.source_wire(glyph.wire) else {
|
||||
continue;
|
||||
};
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if seen.contains(wire.name.as_str()) {
|
||||
@@ -1662,7 +1677,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>(
|
||||
}
|
||||
}
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
if wire.points.len() >= 2
|
||||
@@ -1738,7 +1753,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>(
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::default();
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
// Fallback: when wire has no line geometry (e.g. greek text emits
|
||||
@@ -1826,7 +1841,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>(
|
||||
let mut seen = HashSet::default();
|
||||
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
let name = wire.name.as_str();
|
||||
@@ -1939,7 +1954,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::default();
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
// Same AABB fallback as `box_hit`: when a wire has no line
|
||||
@@ -2029,7 +2044,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>(
|
||||
let mut seen = HashSet::default();
|
||||
|
||||
for wire in wires.iter() {
|
||||
if wire.snap_only {
|
||||
if wire.snap_only || wire.reference_only {
|
||||
continue;
|
||||
}
|
||||
let aabb_pts: Vec<[f32; 3]>;
|
||||
|
||||
@@ -588,6 +588,7 @@ mod tests {
|
||||
let h = doc.add_entity(donut).unwrap();
|
||||
let entity = doc.get_entity(h).unwrap();
|
||||
let selected = rustc_hash::FxHashSet::default();
|
||||
let reference_only = rustc_hash::FxHashSet::default();
|
||||
let wires = crate::scene::convert::tess::tessellate_entity(
|
||||
&doc,
|
||||
&selected,
|
||||
@@ -600,6 +601,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
&reference_only,
|
||||
);
|
||||
assert_eq!(
|
||||
wires.len(),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -204,6 +204,7 @@ impl Scene {
|
||||
let selected = handles
|
||||
.into_iter()
|
||||
.filter(|handle| self.passes_selection_filter(*handle))
|
||||
.filter(|handle| !self.is_reference_only(*handle))
|
||||
.filter(|handle| {
|
||||
self.document.get_entity(*handle).is_some_and(|entity| {
|
||||
self.resident_entity_visible(
|
||||
@@ -1060,7 +1061,15 @@ impl Scene {
|
||||
// ── Erase ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn erase_entities(&mut self, handles: &[Handle]) {
|
||||
self.remove_entities(handles, true);
|
||||
self.remove_entities(handles, true, true);
|
||||
}
|
||||
|
||||
/// Like [`erase_entities`](Self::erase_entities) but without the
|
||||
/// reference-only skip. The plugin owns reference-only entities and
|
||||
/// removes them through the plugin host API; locked-layer policy stays
|
||||
/// enforced either way.
|
||||
pub fn erase_entities_plugin(&mut self, handles: &[Handle]) {
|
||||
self.remove_entities(handles, true, false);
|
||||
}
|
||||
|
||||
/// Roll back entities created by the current command after a downstream
|
||||
@@ -1069,10 +1078,15 @@ impl Scene {
|
||||
/// The shared removal path keeps first-touch undo state, history objects,
|
||||
/// groups, selection, and derived caches coherent.
|
||||
pub(crate) fn rollback_new_entities(&mut self, handles: &[Handle]) {
|
||||
self.remove_entities(handles, false);
|
||||
self.remove_entities(handles, false, false);
|
||||
}
|
||||
|
||||
fn remove_entities(&mut self, handles: &[Handle], respect_layer_locks: bool) {
|
||||
fn remove_entities(
|
||||
&mut self,
|
||||
handles: &[Handle],
|
||||
respect_layer_locks: bool,
|
||||
respect_reference_only: bool,
|
||||
) {
|
||||
let erase_handles = self.handles_expanded_for_leader_annotations(handles);
|
||||
|
||||
// One pass over document.objects instead of a scan per erased handle.
|
||||
@@ -1096,10 +1110,15 @@ impl Scene {
|
||||
let mut hover_changed = false;
|
||||
|
||||
for &h in &erase_handles {
|
||||
// Objects on a locked layer can't be erased.
|
||||
// Objects on a locked layer can't be erased. Reference-only
|
||||
// entities are plugin-owned: only the plugin-owned erase path
|
||||
// may remove them.
|
||||
if respect_layer_locks && self.is_layer_locked(h) {
|
||||
continue;
|
||||
}
|
||||
if respect_reference_only && self.is_reference_only(h) {
|
||||
continue;
|
||||
}
|
||||
let settings_handle = self.document.get_entity(h).and_then(|entity| match entity {
|
||||
EntityType::Extended(extended) => match &extended.data {
|
||||
codec::entities::ExtendedEntityData::SectionObject(data)
|
||||
@@ -1125,6 +1144,12 @@ impl Scene {
|
||||
let before = self.document.objects.get(&manager_handle).cloned();
|
||||
self.record_undo_object_before(manager_handle, before);
|
||||
}
|
||||
// The designation set is scene state, not document state: a
|
||||
// designated entity's set entry joins the same first-touch
|
||||
// recording so undo restores entity and designation together.
|
||||
if self.reference_only.is_marked(h) {
|
||||
self.record_undo_reference_designation_before(h);
|
||||
}
|
||||
}
|
||||
self.delete_solid_history(h);
|
||||
if let Some(settings_handle) = settings_handle {
|
||||
@@ -1143,6 +1168,9 @@ impl Scene {
|
||||
}
|
||||
self.remember_removed_cache_categories(h);
|
||||
self.document.remove_entity_arc(h);
|
||||
// The set entry dies with its entity (an absent entity is never
|
||||
// reference-only). Undo restores it through the recorded channel.
|
||||
self.reference_only.remove(h);
|
||||
selection_changed |= self.selected.remove(&h);
|
||||
self.selected_order.retain(|selected| *selected != h);
|
||||
if self.hover_highlight == Some(h) {
|
||||
|
||||
@@ -382,6 +382,7 @@ pub fn apply_along(
|
||||
depth_override: None,
|
||||
display_visible: true,
|
||||
snap_only: false,
|
||||
reference_only: false,
|
||||
plot_visible: true,
|
||||
fill_is_3d: false,
|
||||
fill_is_2d_solid: false,
|
||||
|
||||
@@ -4177,6 +4177,7 @@ impl Scene {
|
||||
None,
|
||||
None,
|
||||
content_viewport,
|
||||
self.reference_only.handles(),
|
||||
);
|
||||
for wire in &mut context_wires {
|
||||
wire.color = tint;
|
||||
|
||||
Reference in new issue
Block a user