forked from 1b-agent/opencadstudio-upstream
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:
1 parent
1f552038a8
commit
d24adc39b5
16 files changed
+806
-61
No files matched your search
Vendored
+1
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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]>;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user