diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 1bfc64064..39718d7dd 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -107,7 +107,7 @@ impl<'a> HostSession<'a> { *slot = attribute; self.app.tabs[self.tab] .scene - .update_entity(EntityType::Insert(insert)) + .update_entity_plugin(EntityType::Insert(insert)) } pub fn document_view(&mut self) -> Option { @@ -373,7 +373,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 +415,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 +473,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); } @@ -548,7 +548,9 @@ impl<'a> HostSession<'a> { .document() .get_entity(handle) .map(|entity| entity.common().owner_handle); - self.app.tabs[self.tab].scene.erase_entities(&[handle]); + self.app.tabs[self.tab] + .scene + .erase_entities_plugin(&[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 +1140,9 @@ 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.app.tabs[self.tab] + .scene + .erase_entities_plugin(&[source]); self.publish_document_view(); } Ok(handle) @@ -1401,7 +1405,9 @@ 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.app.tabs[self.tab] + .scene + .erase_entities_plugin(&on_layer); } self.finish_layer_change(&[name]); Ok(handle) @@ -2256,7 +2262,9 @@ impl<'a> HostSession<'a> { } } if erase_originals { - self.app.tabs[self.tab].scene.erase_entities(&entities); + self.app.tabs[self.tab] + .scene + .erase_entities_plugin(&entities); } self.finish_block_change(); Ok(handle) @@ -3123,6 +3131,74 @@ 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 survives DWG/DXF save and reload. + /// + /// 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. 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. + pub fn set_reference_only( + &mut self, + handle: Handle, + reference_only: bool, + ) -> Result<(), String> { + // Refusals precede any change (and any undo step). + let Some(entity) = self.document().get_entity(handle) else { + 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 crate::scene::reference_only::entity_is_reference_only(entity) == reference_only { + return Ok(()); + } + let own_scope = self.ensure_xrecord_undo_scope("Set Reference-Only"); + // Register the host APPID before the marker is written so the record's + // application is resolvable on save. + if reference_only { + self.ensure_app_id(crate::scene::reference_only::REFERENCE_ONLY_APP_ID); + } + let app_handle = self + .document() + .app_ids + .get(crate::scene::reference_only::REFERENCE_ONLY_APP_ID) + .map(|app| app.handle.value()); + let changed = self + .document_mut() + .get_entity_mut(handle) + .map(|entity| { + let changed = + crate::scene::reference_only::set_entity_reference_only(entity, reference_only); + // Drop stale verbatim EED for the host app so the fresh + // marker — not pre-edit bytes captured on a prior read — + // wins on the next save. + if changed { + if let Some(app_handle) = app_handle { + entity + .common_mut() + .extended_data + .raw_dwg_eed + .retain(|(a, _)| *a != app_handle); + } + } + changed + }) + .unwrap_or(false); + debug_assert!(changed, "designation state was checked before the mutation"); + 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 +3602,9 @@ impl HostApi for HostSession<'_> { fn list_xrecords(&self, dictionary_path: &[&str]) -> Result, String> { self.list_xrecords(dictionary_path) } + fn set_reference_only(&mut self, handle: Handle, reference_only: bool) -> Result<(), String> { + self.set_reference_only(handle, reference_only) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) @@ -13379,6 +13458,237 @@ 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!( + !crate::scene::reference_only::entity_is_reference_only( + host.document().get_entity(handle).unwrap() + ), + "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 mut 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 mut 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_survives_dxf_and_dwg_round_trips() { + 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 flagged = |document: &codec::CadDocument| { + document + .get_entity(handle) + .is_some_and(crate::scene::reference_only::entity_is_reference_only) + }; + // The host APPID the marker lives under travels with the record. + assert!(host.document().app_ids.get("OCS").is_some()); + + let dwg = codec::DwgWriter::write_to_vec(host.document()).unwrap(); + let reloaded = crate::io::load_bytes("reference-only.dwg", dwg).unwrap(); + assert!(flagged(&reloaded), "DWG save/reopen keeps the designation"); + assert!(reloaded.app_ids.get("OCS").is_some()); + + let dxf = codec::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); + let reloaded = crate::io::load_bytes("reference-only.dxf", dxf).unwrap(); + assert!(flagged(&reloaded), "DXF save/reopen keeps the designation"); + assert!(reloaded.app_ids.get("OCS").is_some()); + } + + #[test] + fn user_paths_refuse_and_plugin_paths_write_reference_only_entities() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + host.set_reference_only(handle, true).unwrap(); + let plain = host.add_entity(reference_test_line(10.0, 5.0, Handle::NULL)); + + // Grips: the reference-only entity contributes none; the editable one + // keeps its own. + host.set_selection(&[handle]).unwrap(); + host.app.refresh_selected_grips(); + assert!( + host.app.tabs[0].selected_grips.is_empty(), + "reference-only entities contribute no grips" + ); + host.set_selection(&[plain]).unwrap(); + host.app.refresh_selected_grips(); + assert!(!host.app.tabs[0].selected_grips.is_empty()); + + // The user in-place update refuses; the user batch skips only the + // reference-only member; the plugin-owned variants write through. + assert!(!host.app.tabs[0] + .scene + .update_entity(reference_test_line(20.0, 0.0, handle))); + let applied = host.app.tabs[0].scene.update_entities(vec![ + reference_test_line(20.0, 0.0, handle), + reference_test_line(20.0, 5.0, plain), + ]); + assert_eq!( + applied, 1, + "the user batch applies only the editable entity" + ); + let end_x = |scene: &crate::scene::Scene, h: Handle| match scene.document.get_entity(h) { + Some(EntityType::Line(line)) => line.end.x, + _ => panic!("entity {h:?} must be a line"), + }; + let scene = &host.app.tabs[0].scene; + assert_eq!( + end_x(scene, handle), + 10.0, + "the user paths refused the write" + ); + assert_eq!(end_x(scene, plain), 20.0); + // The plugin-owned variant writes through. A real plugin refresh + // clones the live entity (designation included), edits it, and + // replaces it — so model that flow instead of a bare synthetic. + let mut moved = match host.app.tabs[0].scene.document.get_entity(handle) { + Some(entity) => entity.clone(), + _ => unreachable!("the entity exists"), + }; + if let EntityType::Line(line) = &mut moved { + line.end.x = 30.0; + } + assert!(host.app.tabs[0].scene.update_entity_plugin(moved)); + let scene = &host.app.tabs[0].scene; + assert_eq!( + end_x(scene, handle), + 30.0, + "the plugin-owned update writes through" + ); + assert!( + scene.is_reference_only(handle), + "a refresh-style plugin write preserves the designation" + ); + + // User erase skips reference-only entities; the plugin erase path + // removes them. + host.app.tabs[0].scene.erase_entities(&[handle]); + assert!( + host.app.tabs[0].scene.document.get_entity(handle).is_some(), + "user erase skips reference-only entities" + ); + host.app.tabs[0].scene.erase_entities_plugin(&[handle]); + assert!( + host.app.tabs[0].scene.document.get_entity(handle).is_none(), + "the plugin erase path removes reference-only entities" + ); + assert!(host.app.tabs[0].scene.document.get_entity(plain).is_some()); + } + + #[test] + fn select_all_visible_skips_reference_only_entities() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let handle = host.add_entity(reference_test_line(10.0, 0.0, Handle::NULL)); + host.set_reference_only(handle, true).unwrap(); + let plain = host.add_entity(reference_test_line(10.0, 5.0, Handle::NULL)); + + assert_eq!(host.app.tabs[0].scene.select_all_visible(), 1); + let selected = host.app.tabs[0].scene.selected_handles_in_order(); + assert_eq!(selected, vec![plain]); + } + #[test] fn add_and_update_auto_register_novel_layers_with_real_handles() { // A plugin adds/edits an entity naming a layer no LAYER command ever diff --git a/src/app/properties.rs b/src/app/properties.rs index 80992ee57..7b521ef51 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2661,10 +2661,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(); @@ -2678,7 +2682,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(); @@ -2691,7 +2696,9 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} selected }; for (handle, entity) in selected { - if self.tabs[i].scene.is_layer_locked(handle) { + if self.tabs[i].scene.is_layer_locked(handle) + || self.tabs[i].scene.is_reference_only(handle) + { continue; } let contextual = crate::scene::annotative::entity_for_annotation_context( diff --git a/src/app/record_api.rs b/src/app/record_api.rs index d2b91e272..53e63a807 100644 --- a/src/app/record_api.rs +++ b/src/app/record_api.rs @@ -245,6 +245,8 @@ fn entity_record(entity: &codec::EntityType) -> Result { "record_type".into(), json!(crate::entities::names::dxf_name(entity)), ); + let reference_only = crate::scene::reference_only::entity_is_reference_only(entity); + object.insert("reference_only".into(), json!(reference_only)); Ok(value) } @@ -1506,6 +1508,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 +1700,55 @@ 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), + ), + )); + fn mark(app: &mut OpenCADStudio, i: usize, handle: Handle, on: bool) { + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::reference_only::set_entity_reference_only(entity, on); + } + } + + // The read surface reports the designation alongside the record. + mark(&mut app, i, 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, i, handle, false); + let records = app.record_query(&json!({"collection":"entities"})); + assert_eq!(records["records"][0]["reference_only"], json!(false)); + } + #[test] fn records_query_and_edit_every_mutable_record_family() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 5aa2108e9..26dd3cac2 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -732,6 +732,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) } @@ -5168,6 +5171,7 @@ properties={:.1}ms picked={}", .into_iter() .filter_map(|s| Scene::handle_from_wire_name(s)) .filter(|&h| self.tabs[i].scene.passes_selection_filter(h)) + .filter(|&h| !self.tabs[i].scene.is_reference_only(h)) .collect(); if cands.len() >= 2 { // Overlap: open the list box at the cursor. diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index cf474df48..0e6c4dffe 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -2344,6 +2344,7 @@ impl MultiLeaderTess for MultiLeader { depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index c82fe304c..a7d2e648f 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1758,6 +1758,7 @@ impl Batches { depth_override: b.local_depth, display_visible: !b.hide_unselected || selected, snap_only: false, + reference_only: false, plot_visible: b.plot_visible, fill_is_3d: b.fill_is_3d, fill_is_2d_solid: b.fill_is_2d_solid, diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 1edecafdd..a2e5e384a 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -547,6 +547,16 @@ 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. + if crate::scene::reference_only::entity_is_reference_only(e) { + for wire in &mut wires { + wire.reference_only = true; + } + } + wires } fn tessellate_entity_inner( diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index ff9e2b0cb..89bac4f8e 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -1808,6 +1808,7 @@ pub fn tessellate( depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, @@ -1973,6 +1974,7 @@ pub fn tessellate( depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index df09813c2..880517d0a 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -519,23 +519,47 @@ impl Scene { /// existing entity. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn update_entity(&mut self, entity: EntityType) -> bool { - let mut changed = Vec::with_capacity(1); - let mut block_change = false; - if !self.update_entity_impl(entity, &mut changed, &mut block_change) { - return false; - } - self.publish_entity_updates(changed, block_change); - true + self.update_entities_policy(vec![entity], false) == 1 } /// Batched variant of [`update_entity`]: writes every replacement, then /// issues a single geometry bump for the whole set instead of one bump per /// entity. pub fn update_entities(&mut self, updates: Vec) -> usize { + self.update_entities_policy(updates, false) + } + + /// Like [`update_entity`](Self::update_entity) but without the + /// reference-only refusal. The plugin owns reference-only entities and + /// keeps full write access through the plugin host API, so a plugin's own + /// refresh of derived display geometry must not be refused. Locked-layer + /// policy stays enforced either way. + pub fn update_entity_plugin(&mut self, entity: EntityType) -> bool { + self.update_entities_policy(vec![entity], true) == 1 + } + + /// Like [`update_entities`](Self::update_entities) but without the + /// reference-only refusal (see [`update_entity_plugin`](Self::update_entity_plugin)). + pub fn update_entities_plugin(&mut self, updates: Vec) -> usize { + self.update_entities_policy(updates, true) + } + + /// Shared write path of the four `update_entity*` entry points. Locked + /// layers always refuse (inside `update_entity_impl`); reference-only + /// entities refuse every caller except the plugin-owned `_plugin` + /// variants, mirroring the `remove_entities` edit-policy split. + fn update_entities_policy( + &mut self, + updates: Vec, + allow_reference_only: bool, + ) -> usize { let mut moved = 0usize; let mut changed = Vec::with_capacity(updates.len()); let mut block_change = false; for entity in updates { + if !allow_reference_only && self.is_reference_only(entity.common().handle) { + continue; + } if self.update_entity_impl(entity, &mut changed, &mut block_change) { moved += 1; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 85a819e1e..779a46109 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -31,6 +31,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; @@ -56,6 +58,7 @@ mod page_setup; mod paper; mod preview; mod project; +pub mod reference_only; mod scene_markers; mod selection; pub mod viewport_dimension_pick; @@ -9145,6 +9148,16 @@ impl Scene { .is_some_and(|l| l.is_locked()) } + /// 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.document + .get_entity(handle) + .is_some_and(reference_only::entity_is_reference_only) + } + /// The name of the locked layer `handle` sits on, if any (for messages). pub fn locked_layer_name(&self, handle: Handle) -> Option { let name = self @@ -10398,6 +10411,21 @@ impl Scene { pub fn interaction_candidate_handles( &self, candidates: &crate::scene::pick::interaction_index::InteractionCandidates, + ) -> Option> { + let handles = self.interaction_candidate_handles_all(candidates); + // Reference-only entities (API v9) are plugin-owned display geometry: + // their fills, solids and instanced meshes are not pickable, so the + // click/hover fallback paths that consume this set skip them and a + // click passes through to the editable entity underneath. + handles.map(|mut handles| { + handles.retain(|h| !self.is_reference_only(*h)); + handles + }) + } + + fn interaction_candidate_handles_all( + &self, + candidates: &crate::scene::pick::interaction_index::InteractionCandidates, ) -> Option> { let mut handles: HashSet = candidates .iter() @@ -10643,7 +10671,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 } @@ -12733,7 +12761,8 @@ vis_index={:.1} visible_probe={:.1}", } /// 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) diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 8ee9fbc0c..2474a490b 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -822,6 +822,11 @@ pub struct WireModel { /// (`display_visible` false) it is what object snaps find; drawn, it is /// display only and snaps skip it too ([`Self::is_display_only`]). pub snap_only: bool, + /// Plugin-owned reference-only source (API v9): the entity is display + /// geometry the built-in edit paths must not pick — clicks fall through + /// to the editable entity underneath. Rendering and snapping are + /// unaffected. + pub reference_only: bool, /// Whether this wire is included in plotted output. pub plot_visible: bool, /// `true` when [`fill_tris`] is a real 3-D surface (PolyfaceMesh / @@ -907,6 +912,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, @@ -1444,6 +1450,7 @@ impl Default for WireModel { depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 50a776218..59d884e73 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -225,7 +225,10 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let mut best_dist = f32::MAX; let mut best: Option<&str> = None; - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { let tolerance = pick_tolerance_px(wire, lw_display, base_radius_px); let mut previous = None; for (index, point) in wire.points.iter().enumerate() { @@ -266,7 +269,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -299,7 +302,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } else { // Q: lazy projection — no Vec allocation per wire; NaN resets the segment chain. for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -354,7 +357,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if let Some(depth) = triangle_ref_hit_depth( @@ -373,7 +376,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.fill_tris.is_empty() { @@ -412,7 +415,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if let Some(depth) = triangle_ref_hit_depth( @@ -431,7 +434,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.pick_tris.is_empty() { @@ -475,7 +478,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let start = glyph.start as usize; @@ -491,7 +494,7 @@ pub fn click_hit<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.text_verts.is_empty() { @@ -526,7 +529,10 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } let mut hits: Vec<(f32, &str)> = Vec::new(); let mut marker_hits: HashMap<&str, f32> = HashMap::default(); - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { let tolerance = pick_tolerance_px(wire, lw_display, base_radius_px); let mut previous = None; for (index, point) in wire.points.iter().enumerate() { @@ -563,7 +569,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -600,7 +606,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( })); } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.point_marker.is_some() { @@ -646,7 +652,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if hits.iter().any(|&(_, name)| name == wire.name) { @@ -670,7 +676,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.fill_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { @@ -706,7 +712,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if hits.iter().any(|&(_, name)| name == wire.name) { @@ -730,7 +736,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.pick_tris.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { @@ -762,7 +768,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if hits.iter().any(|&(_, name)| name == wire.name) { @@ -780,7 +786,7 @@ pub fn click_hits_all<'a, W: WireSource + ?Sized>( } } else { for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.text_verts.is_empty() || hits.iter().any(|&(_, name)| name == wire.name) { @@ -1324,7 +1330,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( // path below run identical code. let hit_name = |segment: &SegmentRef| -> Option<&'a str> { let wire = wires.source_wire(segment.wire)?; - if wire.snap_only { + if wire.snap_only || wire.reference_only { return None; } let start = segment.start as usize; @@ -1369,7 +1375,10 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( } } } - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { if !seen.contains(wire.name.as_str()) && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| segment_hits(a, b)) && seen.insert(wire.name.as_str()) @@ -1385,7 +1394,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if already(&wire_hit, triangle.wire) { @@ -1412,7 +1421,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if already(&wire_hit, glyph.wire) { @@ -1435,7 +1444,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>( // Degenerate point-only wires have no indexed segment or surface primitive. for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.points.len() >= 2 @@ -1494,7 +1503,7 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let start = segment.start as usize; @@ -1517,7 +1526,10 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>( out.push(wire.name.as_str()); } } - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { if !seen.contains(wire.name.as_str()) && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| cuts(a, b)) && seen.insert(wire.name.as_str()) @@ -1528,7 +1540,7 @@ pub fn poly_fence_hit<'a, W: WireSource + ?Sized>( return out; } for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.points.len() < 2 { @@ -1569,7 +1581,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(segment.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let start = segment.start as usize; @@ -1596,7 +1608,10 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( out.push(wire.name.as_str()); } } - for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) { + for wire in wires + .iter() + .filter(|wire| wire.point_marker.is_some() && !wire.reference_only) + { if !seen.contains(wire.name.as_str()) && marker_segment_hit(wire, view_rot, eye, bounds, |a, b| { point_in_polygon(a, poly) @@ -1616,7 +1631,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(triangle.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if seen.contains(wire.name.as_str()) { @@ -1641,7 +1656,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( let Some(wire) = wires.source_wire(glyph.wire) else { continue; }; - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if seen.contains(wire.name.as_str()) { @@ -1662,7 +1677,7 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>( } } for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } if wire.points.len() >= 2 @@ -1738,7 +1753,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( let mut out = Vec::new(); let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } // Fallback: when wire has no line geometry (e.g. greek text emits @@ -1826,7 +1841,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let name = wire.name.as_str(); @@ -1939,7 +1954,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( let mut out = Vec::new(); let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } // Same AABB fallback as `box_hit`: when a wire has no line @@ -2029,7 +2044,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( let mut seen = HashSet::default(); for wire in wires.iter() { - if wire.snap_only { + if wire.snap_only || wire.reference_only { continue; } let aabb_pts: Vec<[f32; 3]>; diff --git a/src/scene/reference_only.rs b/src/scene/reference_only.rs new file mode 100644 index 000000000..7ecfe93c9 --- /dev/null +++ b/src/scene/reference_only.rs @@ -0,0 +1,149 @@ +//! 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. +//! +//! The designation persists as a marker value inside an XDATA record under a +//! host-owned APPID, so it round-trips DWG and DXF with the entity and never +//! collides with plugin APPID namespaces (plugins register their own +//! application names through their manifest). + +use codec::entities::EntityType; +use codec::xdata::{ExtendedDataRecord, XDataValue}; + +/// Host-owned APPID carrying the designation. The name belongs to the host +/// alone; plugin records live under the plugin's own registered APPIDs. +pub const REFERENCE_ONLY_APP_ID: &str = "OCS"; + +/// Marker value (DXF group code 1000) that flags an entity reference-only. +/// Human-readable so an exported file explains itself. +const REFERENCE_ONLY_MARKER: &str = "REFERENCE_ONLY"; + +/// True when `value` is the reference-only marker. +fn is_marker(value: &XDataValue) -> bool { + matches!(value, XDataValue::String(text) if text.as_str() == REFERENCE_ONLY_MARKER) +} + +/// True when `entity` carries the reference-only designation. Tolerant read: +/// a record without the marker (foreign or damaged content) is not +/// reference-only. +pub fn entity_is_reference_only(entity: &EntityType) -> bool { + let Some(record) = entity + .common() + .extended_data + .get_record(REFERENCE_ONLY_APP_ID) + else { + return false; + }; + record.values.iter().any(is_marker) +} + +/// Set (or clear) the designation on `entity`, returning whether the entity +/// changed. Only the marker value is added or removed; foreign values under +/// the host APPID are preserved verbatim, and a record left without values is +/// dropped so a cleared flag leaves no empty record behind. +pub fn set_entity_reference_only(entity: &mut EntityType, reference_only: bool) -> bool { + if entity_is_reference_only(entity) == reference_only { + return false; + } + let xd = &mut entity.common_mut().extended_data; + if reference_only { + let mut record = xd + .get_record(REFERENCE_ONLY_APP_ID) + .cloned() + .unwrap_or_else(|| ExtendedDataRecord::new(REFERENCE_ONLY_APP_ID)); + record + .values + .push(XDataValue::String(REFERENCE_ONLY_MARKER.to_owned())); + xd.upsert_record(record); + } else { + let Some(mut record) = xd.get_record(REFERENCE_ONLY_APP_ID).cloned() else { + return false; + }; + record.values.retain(|value| !is_marker(value)); + if record.values.is_empty() { + xd.remove_record(REFERENCE_ONLY_APP_ID); + } else { + xd.upsert_record(record); + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use codec::entities::Line; + + fn entity() -> EntityType { + EntityType::Line(Line::new()) + } + + #[test] + fn marking_flags_and_unmarking_clears() { + let mut entity = entity(); + assert!(!entity_is_reference_only(&entity)); + assert!(set_entity_reference_only(&mut entity, true)); + assert!(entity_is_reference_only(&entity)); + // Idempotent: a second mark changes nothing and reports it. + assert!(!set_entity_reference_only(&mut entity, true)); + assert!(set_entity_reference_only(&mut entity, false)); + assert!(!entity_is_reference_only(&entity)); + assert!(!set_entity_reference_only(&mut entity, false)); + // A cleared flag leaves no empty record behind. + assert!(entity + .common() + .extended_data + .get_record(REFERENCE_ONLY_APP_ID) + .is_none()); + } + + #[test] + fn foreign_values_under_the_host_app_id_survive() { + let mut entity = entity(); + let mut record = ExtendedDataRecord::new(REFERENCE_ONLY_APP_ID); + record.values.push(XDataValue::Integer16(7)); + entity.common_mut().extended_data.add_record(record); + // A record without the marker is not reference-only. + assert!(!entity_is_reference_only(&entity)); + assert!(set_entity_reference_only(&mut entity, true)); + let record = entity + .common() + .extended_data + .get_record(REFERENCE_ONLY_APP_ID) + .expect("record exists"); + assert_eq!( + record.values, + vec![ + XDataValue::Integer16(7), + XDataValue::String(REFERENCE_ONLY_MARKER.to_owned()), + ] + ); + assert!(set_entity_reference_only(&mut entity, false)); + let record = entity + .common() + .extended_data + .get_record(REFERENCE_ONLY_APP_ID) + .expect("record survives"); + assert_eq!(record.values, vec![XDataValue::Integer16(7)]); + } + + #[test] + fn other_apps_are_untouched() { + let mut entity = entity(); + assert!(set_entity_reference_only(&mut entity, true)); + let mut foreign = ExtendedDataRecord::new("CVIFC"); + foreign.values.push(XDataValue::String("road".to_owned())); + entity.common_mut().extended_data.add_record(foreign); + assert!(set_entity_reference_only(&mut entity, false)); + assert!(entity + .common() + .extended_data + .get_record("CVIFC") + .is_some_and(|record| record.values.len() == 1)); + } +} diff --git a/src/scene/reference_only_tests.rs b/src/scene/reference_only_tests.rs new file mode 100644 index 000000000..5127d6144 --- /dev/null +++ b/src/scene/reference_only_tests.rs @@ -0,0 +1,109 @@ +//! Pick-side tests for the reference-only designation (API v9): clicks pass +//! through reference-only wires and box selections skip them. + +use crate::scene::model::wire_model::WireModel; +use crate::scene::pick::hit_test; +use crate::scene::Scene; +use codec::Handle; +use glam::{DVec3, Mat4}; +use iced::{Point, Rectangle, Size}; + +/// A view whose world coordinates map one-to-one onto screen coordinates +/// (the convention of `block_base_point_tests::WorldAsScreen`). +struct WorldAsScreen { + view_rot: Mat4, + eye: DVec3, + bounds: Rectangle, +} + +impl WorldAsScreen { + fn new(extent: f32) -> Self { + Self { + view_rot: Mat4::from_scale(glam::Vec3::new(2.0 / extent, -2.0 / extent, 1.0)), + eye: DVec3::new(extent as f64 / 2.0, extent as f64 / 2.0, 0.0), + bounds: Rectangle::new(Point::ORIGIN, Size::new(extent, extent)), + } + } + + fn at(&self, point: [f64; 3]) -> Point { + Point::new(point[0] as f32, point[1] as f32) + } +} + +fn segment_wire(name: &str, from: [f64; 3], to: [f64; 3], reference_only: bool) -> WireModel { + WireModel { + name: name.to_owned(), + points: vec![ + [from[0] as f32, from[1] as f32, from[2] as f32], + [to[0] as f32, to[1] as f32, to[2] as f32], + ], + reference_only, + ..WireModel::default() + } +} + +fn click_at(point: [f64; 3], view: &WorldAsScreen, wires: &[WireModel]) -> Option { + hit_test::click_hit( + view.at(point), + wires, + view.view_rot, + view.eye, + view.bounds, + false, + 8.0, + &rustc_hash::FxHashMap::default(), + ) + .and_then(Scene::handle_from_wire_name) +} + +#[test] +fn clicks_pass_through_reference_only_wires() { + let view = WorldAsScreen::new(100.0); + let editable = segment_wire("1", [25.0, 25.0, 0.0], [75.0, 25.0, 0.0], false); + let reference = segment_wire("2", [25.0, 30.0, 0.0], [75.0, 30.0, 0.0], true); + let wires = [editable, reference]; + + // A click sitting exactly on the reference-only wire passes through to + // the editable wire 5 px away (inside the 8 px aperture). + assert_eq!( + click_at([50.0, 30.0, 0.0], &view, &wires), + Some(Handle::new(1)), + "a click on reference-only geometry must resolve to the entity underneath" + ); + assert_eq!( + click_at([50.0, 25.0, 0.0], &view, &wires), + Some(Handle::new(1)) + ); + + // With only the reference-only wire under the cursor the click misses. + let reference_only = [wires[1].clone()]; + assert_eq!(click_at([50.0, 30.0, 0.0], &view, &reference_only), None); + assert_eq!(click_at([50.0, 25.0, 0.0], &view, &reference_only), None); +} + +#[test] +fn box_selections_skip_reference_only_wires() { + let view = WorldAsScreen::new(100.0); + let editable = segment_wire("1", [25.0, 25.0, 0.0], [75.0, 25.0, 0.0], false); + let reference = segment_wire("2", [25.0, 30.0, 0.0], [75.0, 30.0, 0.0], true); + let wires = [editable, reference]; + + let hits = hit_test::box_hit( + view.at([10.0, 10.0, 0.0]), + view.at([90.0, 50.0, 0.0]), + false, + &wires, + view.view_rot, + view.eye, + view.bounds, + ); + let handles: Vec = hits + .into_iter() + .filter_map(Scene::handle_from_wire_name) + .collect(); + assert_eq!( + handles, + vec![Handle::new(1)], + "a box selection must not pick reference-only geometry" + ); +} diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 11b2a67d6..95dc55353 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -203,6 +203,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( @@ -1059,7 +1060,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 @@ -1068,10 +1077,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. @@ -1095,10 +1109,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) diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index 56a3c6f92..49587059a 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -382,6 +382,7 @@ pub fn apply_along( depth_override: None, display_visible: true, snap_only: false, + reference_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false,