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.
This commit is contained in:
Sebastian committed 2026-10-13 12:38:53 +02:00
1 parent 1f552038a8
commit d24adc39b5
16 files changed
+806 -61

No files matched your search

+1
View File
@@ -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,
+10
View File
@@ -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(
+2
View File
@@ -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,
+31 -7
View File
@@ -519,23 +519,47 @@ impl Scene {
/// existing entity.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub fn update_entity(&mut self, entity: EntityType) -> bool {
let mut changed = Vec::with_capacity(1);
let mut block_change = false;
if !self.update_entity_impl(entity, &mut changed, &mut block_change) {
return false;
}
self.publish_entity_updates(changed, block_change);
true
self.update_entities_policy(vec![entity], false) == 1
}
/// Batched variant of [`update_entity`]: writes every replacement, then
/// issues a single geometry bump for the whole set instead of one bump per
/// entity.
pub fn update_entities(&mut self, updates: Vec<EntityType>) -> usize {
self.update_entities_policy(updates, false)
}
/// Like [`update_entity`](Self::update_entity) but without the
/// reference-only refusal. The plugin owns reference-only entities and
/// keeps full write access through the plugin host API, so a plugin's own
/// refresh of derived display geometry must not be refused. Locked-layer
/// policy stays enforced either way.
pub fn update_entity_plugin(&mut self, entity: EntityType) -> bool {
self.update_entities_policy(vec![entity], true) == 1
}
/// Like [`update_entities`](Self::update_entities) but without the
/// reference-only refusal (see [`update_entity_plugin`](Self::update_entity_plugin)).
pub fn update_entities_plugin(&mut self, updates: Vec<EntityType>) -> usize {
self.update_entities_policy(updates, true)
}
/// Shared write path of the four `update_entity*` entry points. Locked
/// layers always refuse (inside `update_entity_impl`); reference-only
/// entities refuse every caller except the plugin-owned `_plugin`
/// variants, mirroring the `remove_entities` edit-policy split.
fn update_entities_policy(
&mut self,
updates: Vec<EntityType>,
allow_reference_only: bool,
) -> usize {
let mut moved = 0usize;
let mut changed = Vec::with_capacity(updates.len());
let mut block_change = false;
for entity in updates {
if !allow_reference_only && self.is_reference_only(entity.common().handle) {
continue;
}
if self.update_entity_impl(entity, &mut changed, &mut block_change) {
moved += 1;
}
+31 -2
View File
@@ -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<String> {
let name = self
@@ -10398,6 +10411,21 @@ impl Scene {
pub fn interaction_candidate_handles(
&self,
candidates: &crate::scene::pick::interaction_index::InteractionCandidates,
) -> Option<HashSet<Handle>> {
let handles = self.interaction_candidate_handles_all(candidates);
// Reference-only entities (API v9) are plugin-owned display geometry:
// their fills, solids and instanced meshes are not pickable, so the
// click/hover fallback paths that consume this set skip them and a
// click passes through to the editable entity underneath.
handles.map(|mut handles| {
handles.retain(|h| !self.is_reference_only(*h));
handles
})
}
fn interaction_candidate_handles_all(
&self,
candidates: &crate::scene::pick::interaction_index::InteractionCandidates,
) -> Option<HashSet<Handle>> {
let mut handles: HashSet<Handle> = candidates
.iter()
@@ -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)
+7
View File
@@ -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,
+50 -35
View File
@@ -225,7 +225,10 @@ pub fn click_hit<'a, W: WireSource + ?Sized>(
let mut best_dist = f32::MAX;
let mut best: 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]>;
+149
View File
@@ -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));
}
}
+109
View File
@@ -0,0 +1,109 @@
//! Pick-side tests for the reference-only designation (API v9): clicks pass
//! through reference-only wires and box selections skip them.
use crate::scene::model::wire_model::WireModel;
use crate::scene::pick::hit_test;
use crate::scene::Scene;
use codec::Handle;
use glam::{DVec3, Mat4};
use iced::{Point, Rectangle, Size};
/// A view whose world coordinates map one-to-one onto screen coordinates
/// (the convention of `block_base_point_tests::WorldAsScreen`).
struct WorldAsScreen {
view_rot: Mat4,
eye: DVec3,
bounds: Rectangle,
}
impl WorldAsScreen {
fn new(extent: f32) -> Self {
Self {
view_rot: Mat4::from_scale(glam::Vec3::new(2.0 / extent, -2.0 / extent, 1.0)),
eye: DVec3::new(extent as f64 / 2.0, extent as f64 / 2.0, 0.0),
bounds: Rectangle::new(Point::ORIGIN, Size::new(extent, extent)),
}
}
fn at(&self, point: [f64; 3]) -> Point {
Point::new(point[0] as f32, point[1] as f32)
}
}
fn segment_wire(name: &str, from: [f64; 3], to: [f64; 3], reference_only: bool) -> WireModel {
WireModel {
name: name.to_owned(),
points: vec![
[from[0] as f32, from[1] as f32, from[2] as f32],
[to[0] as f32, to[1] as f32, to[2] as f32],
],
reference_only,
..WireModel::default()
}
}
fn click_at(point: [f64; 3], view: &WorldAsScreen, wires: &[WireModel]) -> Option<Handle> {
hit_test::click_hit(
view.at(point),
wires,
view.view_rot,
view.eye,
view.bounds,
false,
8.0,
&rustc_hash::FxHashMap::default(),
)
.and_then(Scene::handle_from_wire_name)
}
#[test]
fn clicks_pass_through_reference_only_wires() {
let view = WorldAsScreen::new(100.0);
let editable = segment_wire("1", [25.0, 25.0, 0.0], [75.0, 25.0, 0.0], false);
let reference = segment_wire("2", [25.0, 30.0, 0.0], [75.0, 30.0, 0.0], true);
let wires = [editable, reference];
// A click sitting exactly on the reference-only wire passes through to
// the editable wire 5 px away (inside the 8 px aperture).
assert_eq!(
click_at([50.0, 30.0, 0.0], &view, &wires),
Some(Handle::new(1)),
"a click on reference-only geometry must resolve to the entity underneath"
);
assert_eq!(
click_at([50.0, 25.0, 0.0], &view, &wires),
Some(Handle::new(1))
);
// With only the reference-only wire under the cursor the click misses.
let reference_only = [wires[1].clone()];
assert_eq!(click_at([50.0, 30.0, 0.0], &view, &reference_only), None);
assert_eq!(click_at([50.0, 25.0, 0.0], &view, &reference_only), None);
}
#[test]
fn box_selections_skip_reference_only_wires() {
let view = WorldAsScreen::new(100.0);
let editable = segment_wire("1", [25.0, 25.0, 0.0], [75.0, 25.0, 0.0], false);
let reference = segment_wire("2", [25.0, 30.0, 0.0], [75.0, 30.0, 0.0], true);
let wires = [editable, reference];
let hits = hit_test::box_hit(
view.at([10.0, 10.0, 0.0]),
view.at([90.0, 50.0, 0.0]),
false,
&wires,
view.view_rot,
view.eye,
view.bounds,
);
let handles: Vec<Handle> = hits
.into_iter()
.filter_map(Scene::handle_from_wire_name)
.collect();
assert_eq!(
handles,
vec![Handle::new(1)],
"a box selection must not pick reference-only geometry"
);
}
+23 -4
View File
@@ -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)
+1
View File
@@ -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,