From d24adc39b5ad8141844c478edb5c9bcdc43805f5 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:38:53 +0200 Subject: [PATCH] feat(scene): reference-only entities refuse the edit paths (API v9 host) Host side of HostApi::set_reference_only: plugin-owned display geometry that renders and snaps normally but must not be edited or picked by the person at the screen, while the owning plugin keeps full write access through the host. - Storage: a human-readable marker value in an XDATA record under the host-owned OCS APPID (registered on first use; foreign values survive verbatim; stale verbatim DWG EED is dropped on rewrite), so the flag round-trips DWG and DXF with the entity and cannot collide with plugin APPID namespaces. - Grips: refresh_selected_grips yields none for a reference-only entity and an active grip edit on a newly designated target is cancelled like a locked-layer one. - Picking: a reference-only wire flag extends the snap_only skips in every hit test, so a click passes through to the editable entity underneath, and box, window, lasso, fence and select-all gathers skip the designation; fills, solids and instanced meshes are excluded through the shared candidate handle set. - Write policy: built-in user paths treat a reference-only entity like a locked-layer one (Scene::update_entity / update_entities refuse, erase_entities skips), while the plugin-owned variants (update_entity_plugin / erase_entities_plugin, mirroring the remove_entities editor split) and the whole HostSession surface write through. The MCP entities read exposes a top-level reference_only boolean and set_properties refuses with the reference_only failure code. - Undo: the entity-write contract - a change joins a plugin-opened push_undo scope, a scope-less change is one undo step of its own, refusals and idempotent re-marks record nothing. - Known limit: entities nested inside block instances (the block cache rebuilds instance wires without the flag). Evidence: marking_flags_and_unmarking_clears, foreign_values_under_the_host_app_id_survive, other_apps_are_untouched, reference_only_refusals_record_no_undo_step, reference_only_designation_composes_undo_like_entity_writes, reference_only_survives_dxf_and_dwg_round_trips, user_paths_refuse_and_plugin_paths_write_reference_only_entities, select_all_visible_skips_reference_only_entities, clicks_pass_through_reference_only_wires, box_selections_skip_reference_only_wires, entities_records_expose_the_reference_only_designation. --- src/app/plugin_host.rs | 326 +++++++++++++++++++++++++++++- src/app/properties.rs | 17 +- src/app/record_api.rs | 57 ++++++ src/app/update/viewport.rs | 4 + src/entities/multileader.rs | 1 + src/scene/cache/block_cache.rs | 1 + src/scene/convert/tess.rs | 10 + src/scene/convert/tessellate.rs | 2 + src/scene/entity.rs | 38 +++- src/scene/mod.rs | 33 ++- src/scene/model/wire_model.rs | 7 + src/scene/pick/hit_test.rs | 85 ++++---- src/scene/reference_only.rs | 149 ++++++++++++++ src/scene/reference_only_tests.rs | 109 ++++++++++ src/scene/selection.rs | 27 ++- src/scene/text/complex_lt.rs | 1 + 16 files changed, 806 insertions(+), 61 deletions(-) create mode 100644 src/scene/reference_only.rs create mode 100644 src/scene/reference_only_tests.rs 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,