Merge pull request #1747 from schoeller/reference_only_entities

Reference-only entities (plugin API v9): a scene-scoped, in-memory
designation for plugin-owned display geometry, plus HostNotification
dispatch to native plugins so they can re-mark on DocumentOpened.

Resolves the new DeltaSnapshot site in block_editor.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-14 23:46:42 +03:00
commit 68330ce39e
40 files changed
+1977 -85

No files matched your search

+5
View File
@@ -792,6 +792,9 @@ fn tessellate_sub_local(
// top-level entities, applied per-defn.
let wires_out = if let EntityType::Dimension(dimension) = sub {
use crate::entities::dimension::DimensionTess;
// Block-definition members never carry the reference-only
// designation (it targets top-level geometry — see the API v9
// known-limit note), so the block cache tessellates with an empty set.
dimension.tessellate(
doc,
h,
@@ -804,6 +807,7 @@ fn tessellate_sub_local(
bg_color,
None,
None,
&HashSet::default(),
)
} else {
tessellate::tessellate(
@@ -1840,6 +1844,7 @@ impl Batches {
depth_override: b.local_depth,
display_visible: !b.hide_unselected || selected,
snap_only: false,
reference_only: false,
plot_visible: b.plot_visible,
fill_is_3d: b.fill_is_3d,
fill_is_2d_solid: b.fill_is_2d_solid,
+24
View File
@@ -473,6 +473,7 @@ fn section_symbol_wires(
/// so it picks up the standard text LOD ladder (baseline / greek / full),
/// then re-color the returned wires with the dimension's resolved text colour
/// (so DIMCLRT / DIMSTYLE colours win over the synthetic Text's defaults).
#[allow(clippy::too_many_arguments)]
pub(crate) fn tessellate_entity_dim_text(
document: &codec::CadDocument,
selected: &HashSet<Handle>,
@@ -483,6 +484,7 @@ pub(crate) fn tessellate_entity_dim_text(
view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>,
text_color: [f32; 4],
reference_only: &HashSet<Handle>,
) -> Vec<WireModel> {
let mut wires = tessellate_entity(
document,
@@ -496,6 +498,7 @@ pub(crate) fn tessellate_entity_dim_text(
view_aabb,
world_per_pixel,
false,
reference_only,
);
for w in &mut wires {
// Synth dim text carries no real entity colour — paint everything
@@ -508,6 +511,7 @@ pub(crate) fn tessellate_entity_dim_text(
}
wires
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn tessellate_entity(
document: &codec::CadDocument,
selected: &HashSet<Handle>,
@@ -520,6 +524,7 @@ pub(crate) fn tessellate_entity(
view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>,
paper_space: bool,
reference_only: &HashSet<Handle>,
) -> Vec<WireModel> {
let mut wires = tessellate_entity_inner(
document,
@@ -533,6 +538,7 @@ pub(crate) fn tessellate_entity(
view_aabb,
world_per_pixel,
paper_space,
reference_only,
);
let layer_plottable = document
@@ -547,8 +553,21 @@ pub(crate) fn tessellate_entity(
}
}
// Reference-only entities (API v9) render and snap normally but must not
// be picked by the built-in edit paths; the pick-side wire filters in
// `scene::pick::hit_test` and the selection-gather retains consume the
// flag. The stamp is driven by the scene's in-memory designation set —
// outside `Scene` this stays pure data, so the parallel tessellation
// workers only need the handle set, not scene access.
if reference_only.contains(&e.common().handle) {
for wire in &mut wires {
wire.reference_only = true;
}
}
wires
}
#[allow(clippy::too_many_arguments)]
fn tessellate_entity_inner(
document: &codec::CadDocument,
selected: &HashSet<Handle>,
@@ -567,6 +586,10 @@ fn tessellate_entity_inner(
// Retained through recursive INSERT expansion; PSLTSCALE itself is applied
// by the viewport's GPU uniform so it never changes resident wire content.
_paper_space: bool,
// The scene's reference-only designation set (API v9): drives the
// `WireModel::reference_only` stamp for dimensions' synthesis paths that
// bypass the wrapper's post-stamp.
reference_only: &HashSet<Handle>,
) -> Vec<WireModel> {
let contextual = crate::scene::annotative::entity_for_annotation_context(
document,
@@ -1019,6 +1042,7 @@ fn tessellate_entity_inner(
bg_color,
view_aabb,
world_per_pixel,
reference_only,
);
for w in &mut wires {
w.aci = aci;
+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,
@@ -1974,6 +1975,7 @@ pub fn tessellate(
depth_override: None,
display_visible: true,
snap_only: false,
reference_only: false,
plot_visible: true,
fill_is_3d: false,
fill_is_2d_solid: false,
+1 -1
View File
@@ -2279,7 +2279,7 @@ mod tests {
scene.sync_diameter_association_angle(dimension);
let recording = scene.take_undo_recording().expect("undo recording");
let (_, objects, _, _) = recording.into_recorded_images();
let (_, objects, _, _, _) = recording.into_recorded_images();
assert!(objects
.iter()
.any(|(handle, before)| *handle == association && before.is_some()));
+31 -7
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;
}
+98 -4
View File
@@ -36,6 +36,8 @@ pub enum BlockScalePolicy {
mod block_base_point_tests;
mod boundary;
mod camera_ops;
#[cfg(test)]
mod reference_only_tests;
pub use camera_ops::{add_background, copy_background, erase_background};
pub(crate) mod centerline;
pub(crate) mod centermark;
@@ -61,6 +63,7 @@ mod page_setup;
mod paper;
mod preview;
mod project;
pub mod reference_only;
mod scene_markers;
mod selection;
pub mod viewport_dimension_pick;
@@ -84,6 +87,10 @@ pub(crate) use convert::tess::{
/// Keep construction-history curves in the same per-entity tessellation memo
/// as their parent, so navigation never rebuilds them on every frame.
///
/// `reference_only` is the scene's designation set: its membership decides
/// whether the produced wires carry the `WireModel::reference_only` click-
/// through stamp (see `scene::reference_only`).
#[allow(clippy::too_many_arguments)]
pub(crate) fn tessellate_entity(
document: &CadDocument,
@@ -97,6 +104,7 @@ pub(crate) fn tessellate_entity(
view_aabb: Option<[f32; 4]>,
world_per_pixel: Option<f32>,
paper_space: bool,
reference_only: &HashSet<Handle>,
) -> Vec<WireModel> {
let mut wires = convert::tess::tessellate_entity(
document,
@@ -110,6 +118,7 @@ pub(crate) fn tessellate_entity(
view_aabb,
world_per_pixel,
paper_space,
reference_only,
);
if matches!(entity, EntityType::Solid3D(_) | EntityType::Surface(_)) {
let handle = entity.common().handle;
@@ -161,6 +170,7 @@ pub(crate) fn tessellate_entity(
view_aabb,
world_per_pixel,
paper_space,
reference_only,
)
};
for wire in &mut construction {
@@ -760,6 +770,13 @@ pub struct UndoRecording {
>,
parametric_constraints_order: Vec<parametric_constraints::ParametricScope>,
named_parameters_before: Option<named_parameters::ParameterTable>,
/// First-touch before-images of reference-only designations (API v9)
/// changed by the command — almost always empty. The designation set
/// lives on `Scene`, not in the document, so like
/// `parametric_constraints_before` it needs its own channel. Erasing a
/// designated entity drops its set entry here so undo restores entity
/// and designation together.
reference_designations_before: Vec<(Handle, bool)>,
poisoned: bool,
}
@@ -775,6 +792,7 @@ impl UndoRecording {
&& self.object_order.is_empty()
&& self.parametric_constraints_order.is_empty()
&& self.named_parameters_before.is_none()
&& self.reference_designations_before.is_empty()
}
/// Consume the recording directories in deterministic first-touch
@@ -792,6 +810,7 @@ impl UndoRecording {
parametric_constraints::ParametricConstraintSet,
)>,
Option<named_parameters::ParameterTable>,
Vec<(Handle, bool)>,
) {
let entities = self
.order
@@ -812,11 +831,13 @@ impl UndoRecording {
.map(|before| (scope, before))
})
.collect();
let reference_designations = std::mem::take(&mut self.reference_designations_before);
(
entities,
objects,
parametric_constraints,
self.named_parameters_before,
reference_designations,
)
}
@@ -2138,6 +2159,11 @@ pub struct Scene {
pub selected_constraint: Option<crate::scene::parametric_constraints::ConstraintId>,
/// Session-only ISOLATEOBJECTS / HIDEOBJECTS state. Never written to DWG/DXF.
pub object_isolation: ObjectIsolationState,
/// Scene-scoped reference-only designations (API v9): plugin-owned
/// display geometry the built-in edit paths refuse. In-memory only,
/// keyed by handle, lives and dies with this scene — never written to
/// DWG/DXF (see `scene::reference_only`).
pub(crate) reference_only: reference_only::ReferenceOnlyDesignation,
/// Entity handles temporarily removed from the base render while an
/// interactive grip preview draws their live replacement.
/// Separate from object isolation so a grip can never activate the
@@ -2782,6 +2808,7 @@ impl Scene {
selected_order: Vec::new(),
selected_constraint: None,
object_isolation: ObjectIsolationState::default(),
reference_only: Default::default(),
preview_hidden: HashSet::default(),
command_preview_hidden: HashSet::default(),
refedit_keep: None,
@@ -3434,6 +3461,26 @@ impl Scene {
}
}
/// Record a reference-only designation's value before its first change
/// within the open delta recording (API v9). The designation set lives on
/// `Scene`, outside the document, so it needs its own channel — like
/// the parametric-constraint directory above — for undo to restore the
/// designation together with the entity it belongs to. First touch wins.
pub(crate) fn record_undo_reference_designation_before(&mut self, handle: Handle) {
if let Some(recording) = self.undo_recording.as_mut() {
if !recording
.reference_designations_before
.iter()
.any(|(recorded, _)| *recorded == handle)
{
let before = self.reference_only.is_marked(handle);
recording
.reference_designations_before
.push((handle, before));
}
}
}
/// Flag the open recording as touching non-entity state (a new layer, a
/// group edit, a `*D` block record) that a pure-entity delta cannot
/// restore. The app's per-command predicate keeps this from firing.
@@ -3491,6 +3538,13 @@ impl Scene {
}
}
// History replay can apply an entity removal the designation channels
// predate (an "added" side rolled back). Sweep so the set never keeps
// an entry for an absent entity; exact designations come right after
// this from the delta's own channel.
if !self.reference_only.is_empty() {
self.reference_only.retain_present(&self.document);
}
if !changes.is_empty() {
self.invalidate_dependency_index();
}
@@ -7555,6 +7609,7 @@ impl Scene {
None,
None,
anno_scale_override.is_some(),
self.reference_only.handles(),
);
// Solid B-rep corners snap via the mesh edge list (their wire
// path is an empty shell). Enrich before the memo/assembly split
@@ -9472,6 +9527,22 @@ impl Scene {
.is_some_and(|keep| !keep.contains(&handle)))
}
/// True when `handle` addresses a reference-only entity (API v9):
/// plugin-owned display geometry the built-in edit paths treat like a
/// locked-layer entity while its owning plugin keeps write access through
/// the plugin host API. Absent entities are never reference-only.
pub fn is_reference_only(&self, handle: Handle) -> bool {
self.reference_only.is_marked(handle) && self.document.get_entity(handle).is_some()
}
/// Insert or remove `handle` in the scene-scoped designation set,
/// returning whether membership changed. Pure storage: no epoch bump, no
/// undo bookkeeping — those belong to the callers (the plugin host API
/// mutation and the history channels).
pub(crate) fn set_reference_only_membership(&mut self, handle: Handle, on: bool) -> bool {
self.reference_only.set(handle, on)
}
/// The name of the locked layer `handle` sits on, if any (for messages).
pub fn locked_layer_name(&self, handle: Handle) -> Option<String> {
let name = self
@@ -10763,6 +10834,21 @@ impl Scene {
pub fn interaction_candidate_handles(
&self,
candidates: &crate::scene::pick::interaction_index::InteractionCandidates,
) -> Option<HashSet<Handle>> {
let handles = self.interaction_candidate_handles_all(candidates);
// Reference-only entities (API v9) are plugin-owned display geometry:
// their fills, solids and instanced meshes are not pickable, so the
// click/hover fallback paths that consume this set skip them and a
// click passes through to the editable entity underneath.
handles.map(|mut handles| {
handles.retain(|h| !self.is_reference_only(*h));
handles
})
}
fn interaction_candidate_handles_all(
&self,
candidates: &crate::scene::pick::interaction_index::InteractionCandidates,
) -> Option<HashSet<Handle>> {
let mut handles: HashSet<Handle> = candidates
.iter()
@@ -11008,7 +11094,7 @@ impl Scene {
candidate_handles.as_ref(),
));
}
handles.retain(|&h| self.passes_selection_filter(h));
handles.retain(|&h| self.passes_selection_filter(h) && !self.is_reference_only(h));
handles
}
@@ -11684,6 +11770,9 @@ impl Scene {
// `world_offset` subtraction even though `current_layout != "Model"`.
// Decide based on the block being tessellated, not the layout.
let is_model_block = block_handle == self.model_space_block_handle();
// The reference-only designation set drives the wire stamp; captured
// as a shared ref like `doc`/`sel` so the rayon workers can read it.
let reference_only_set = self.reference_only.handles();
let bg = if self.current_layout == "Model" {
self.bg_color
} else {
@@ -11800,6 +11889,7 @@ impl Scene {
view_aabb,
wpp,
paper,
reference_only_set,
);
(e.common().handle, Arc::new(w))
})
@@ -11846,6 +11936,7 @@ impl Scene {
view_aabb,
wpp,
paper,
reference_only_set,
)
})
.collect();
@@ -11880,6 +11971,7 @@ impl Scene {
view_aabb,
wpp,
paper,
reference_only_set,
)),
Ok(None) => Some(Vec::new()),
Err(()) => None,
@@ -12900,6 +12992,7 @@ vis_index={:.1} visible_probe={:.1}",
None,
None,
paper,
self.reference_only.handles(),
);
// Same solid-corner snaps as the resident paths, so one-off consumers
// (overlay changed-wires, edit previews) agree with hit-test/snap.
@@ -13220,7 +13313,8 @@ pub(crate) fn drop_layout_viewports(document: &mut codec::CadDocument, block: Ha
}
/// LAYOUTCREATEVIEWPORT: a layout opened for the first time gets a viewport.
static LAYOUT_CREATE_VIEWPORT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
static LAYOUT_CREATE_VIEWPORT: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(true);
pub fn layout_create_viewport() -> bool {
LAYOUT_CREATE_VIEWPORT.load(std::sync::atomic::Ordering::Relaxed)
@@ -14507,7 +14601,7 @@ mod delta_undo_tests {
let handle = scene.add_entity(EntityType::RasterImage(image));
let rec = scene.take_undo_recording().unwrap();
assert!(!rec.is_poisoned());
let (entities, objects, _parametric_constraints, _named_parameters) =
let (entities, objects, _parametric_constraints, _named_parameters, _designations) =
rec.into_recorded_images();
assert_eq!(entities.len(), 1);
assert_eq!(entities[0].0, handle);
@@ -14532,7 +14626,7 @@ mod delta_undo_tests {
scene.erase_entities(&[h1]);
let rec = scene.take_undo_recording().unwrap();
assert!(!rec.is_poisoned());
let (entities, objects, _parametric_constraints, _named_parameters) =
let (entities, objects, _parametric_constraints, _named_parameters, _designations) =
rec.into_recorded_images();
assert_eq!(entities.len(), 1);
assert_eq!(entities[0].0, h1);
+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 /
@@ -910,6 +915,7 @@ impl WireModel {
depth_override: None,
display_visible: true,
snap_only: false,
reference_only: false,
plot_visible: true,
fill_is_3d: false,
fill_is_2d_solid: false,
@@ -1448,6 +1454,7 @@ impl Default for WireModel {
depth_override: None,
display_visible: true,
snap_only: false,
reference_only: false,
plot_visible: true,
fill_is_3d: false,
fill_is_2d_solid: false,
+2 -2
View File
@@ -4502,7 +4502,7 @@ mod tests {
.take_undo_recording()
.expect("an undo recording should still be open");
let (entities, _objects, _parametric_constraints, _named_parameters) =
let (entities, _objects, _parametric_constraints, _named_parameters, _designations) =
recording.into_recorded_images();
let touched: std::collections::HashSet<_> = entities.iter().map(|(h, _)| *h).collect();
assert!(
@@ -4559,7 +4559,7 @@ mod tests {
let recording = scene
.take_undo_recording()
.expect("an undo recording should still be open");
let (_entities, _objects, parametric_constraints, _named_parameters) =
let (_entities, _objects, parametric_constraints, _named_parameters, _designations) =
recording.into_recorded_images();
assert_eq!(
parametric_constraints.len(),
+50 -35
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]>;
+2
View File
@@ -588,6 +588,7 @@ mod tests {
let h = doc.add_entity(donut).unwrap();
let entity = doc.get_entity(h).unwrap();
let selected = rustc_hash::FxHashSet::default();
let reference_only = rustc_hash::FxHashSet::default();
let wires = crate::scene::convert::tess::tessellate_entity(
&doc,
&selected,
@@ -600,6 +601,7 @@ mod tests {
None,
None,
false,
&reference_only,
);
assert_eq!(
wires.len(),
+187
View File
@@ -0,0 +1,187 @@
//! Reference-only designation (API v9).
//!
//! A reference-only entity is plugin-owned display geometry: it renders and
//! snaps normally, but the built-in edit paths treat it like an entity on a
//! locked layer — no grips, no click picking (a click passes through to the
//! editable entity underneath), and user-driven writes refuse it. The owning
//! plugin keeps full write access through the plugin host API, so it can
//! refresh derived geometry in place.
//!
//! Storage is a scene-scoped in-memory set of entity handles — the host
//! writes nothing of its own into the saved DWG/DXF: no APPID registration,
//! no XDATA marker on the entity, no host-owned state a saved file depends
//! on. The set lives and dies with the scene (per drawing tab): closing a
//! tab forgets its designations, and a freshly opened drawing starts
//! undesignated. The owning plugin re-marks its entities when the host
//! dispatches the document-opened notification (API v9's
//! `BuiltinPlugin::on_notification` surface), which makes the in-memory
//! designation fully equivalent to a persisted one for its use case.
//!
//! Undo keeps the entity-write contract: a designation change performed
//! inside a plugin-opened `push_undo` scope joins that scope, a scope-less
//! change is one undo step of its own, and undo/redo restores the
//! designation with the entities it belongs to. Because the set is not part
//! of the document, set mutations are recorded as explicit first-touch
//! before-images on the history delta (see
//! `DeltaSnapshot::reference_designations`), the same channel pattern
//! `parametric_constraints` uses.
use codec::types::Handle;
use rustc_hash::FxHashSet;
/// The in-memory designation set owned by a [`Scene`](crate::Scene).
///
/// Deliberately tiny: a plain set of handles plus the operations the scene,
/// the plugin host and the history channels use. Not `Serialize` — the set
/// is never written anywhere and never leaves the process.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct ReferenceOnlyDesignation {
handles: FxHashSet<Handle>,
}
impl ReferenceOnlyDesignation {
/// Raw membership (by handle) — the caller guarantees the entity exists,
/// or accepts a stale entry; [`Scene::is_reference_only`] does the
/// existential check.
pub(crate) fn is_marked(&self, handle: Handle) -> bool {
self.handles.contains(&handle)
}
/// Insert or remove `handle`, returning whether the membership changed.
/// Idempotent: re-marking a designated handle reports `false` and touches
/// nothing, which is what terminates the re-mark loop driven by the
/// document-changed notification dispatch.
pub(crate) fn set(&mut self, handle: Handle, on: bool) -> bool {
if on {
self.handles.insert(handle)
} else {
self.handles.remove(&handle)
}
}
/// Forget `handle` — called when its entity is erased so the set never
/// leaks an entry for a removed entity. The undo channels restore the
/// entry together with the entity on undo.
pub(crate) fn remove(&mut self, handle: Handle) {
self.handles.remove(&handle);
}
/// The marked handles, for the tessellation pipelines (wire stamping)
/// and the history channels.
pub(crate) fn handles(&self) -> &FxHashSet<Handle> {
&self.handles
}
/// True when nothing is designated.
pub(crate) fn is_empty(&self) -> bool {
self.handles.is_empty()
}
/// Drop entries whose entities no longer exist. The undo/redo channels
/// model the exact designation state, so this is only a defensive sweep
/// after history replay (an entity the chosen delta side removed must
/// never stay designated through a stale entry).
pub(crate) fn retain_present(&mut self, document: &codec::CadDocument) {
self.handles
.retain(|handle| document.get_entity(*handle).is_some());
}
}
#[cfg(test)]
mod tests {
use super::*;
use codec::entities::{EntityType, Line};
fn handle(value: u64) -> Handle {
Handle::new(value)
}
#[test]
fn marking_and_unmarking_report_and_track_membership() {
let mut designation = ReferenceOnlyDesignation::default();
assert!(!designation.is_marked(handle(7)));
assert!(designation.set(handle(7), true));
assert!(designation.is_marked(handle(7)));
// Idempotent: a second mark changes nothing and reports it — the
// property that terminates re-mark loops.
assert!(!designation.set(handle(7), true));
assert!(designation.set(handle(7), false));
assert!(!designation.is_marked(handle(7)));
assert!(!designation.set(handle(7), false));
assert!(designation.handles().is_empty());
}
#[test]
fn removing_a_handle_forgets_it() {
let mut designation = ReferenceOnlyDesignation::default();
designation.set(handle(7), true);
designation.remove(handle(7));
assert!(!designation.is_marked(handle(7)));
// Removing an unmarked handle is a no-op.
designation.remove(handle(8));
assert!(designation.is_empty());
}
#[test]
fn retain_present_drops_entries_of_absent_entities() {
let mut document = codec::CadDocument::new();
let live = document.add_entity(EntityType::Line(Line::new())).unwrap();
let mut designation = ReferenceOnlyDesignation::default();
designation.set(live, true);
designation.set(handle(999_999), true);
designation.retain_present(&document);
assert!(designation.is_marked(live));
assert!(!designation.is_marked(handle(999_999)));
}
#[test]
fn scene_membership_is_guarded_by_entity_presence() {
let mut scene = crate::scene::Scene::new();
let handle = scene
.document
.add_entity(EntityType::Line(Line::new()))
.unwrap();
assert!(!scene.is_reference_only(handle));
assert!(scene.set_reference_only_membership(handle, true));
assert!(scene.is_reference_only(handle));
// A stale set entry alone never designates: absent entities are not
// reference-only even if the set still lists them.
scene.set_reference_only_membership(Handle::new(999_999), true);
assert!(!scene.is_reference_only(Handle::new(999_999)));
}
#[test]
fn tessellated_wires_stamp_from_the_designation_set() {
let document = codec::CadDocument::new();
let line = EntityType::Line(Line::new());
let handle = line.common().handle;
let selected: rustc_hash::FxHashSet<Handle> = Default::default();
let none: rustc_hash::FxHashSet<Handle> = Default::default();
let mut designated: rustc_hash::FxHashSet<Handle> = Default::default();
designated.insert(handle);
let context = crate::scene::cache::block_cache::BlockCache::default();
for designated in [&none, &designated] {
let wires = crate::scene::tessellate_entity(
&document,
&selected,
None,
[0.0, 0.0, 0.0, 1.0],
1.0,
None,
&line,
Some(&context),
None,
None,
false,
designated,
);
assert!(!wires.is_empty());
assert!(
wires
.iter()
.all(|wire| wire.reference_only == designated.contains(&handle)),
"every wire carries the set's stamp for its entity"
);
}
}
}
+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"
);
}
+32 -4
View File
@@ -204,6 +204,7 @@ impl Scene {
let selected = handles
.into_iter()
.filter(|handle| self.passes_selection_filter(*handle))
.filter(|handle| !self.is_reference_only(*handle))
.filter(|handle| {
self.document.get_entity(*handle).is_some_and(|entity| {
self.resident_entity_visible(
@@ -1060,7 +1061,15 @@ impl Scene {
// ── Erase ─────────────────────────────────────────────────────────────
pub fn erase_entities(&mut self, handles: &[Handle]) {
self.remove_entities(handles, true);
self.remove_entities(handles, true, true);
}
/// Like [`erase_entities`](Self::erase_entities) but without the
/// reference-only skip. The plugin owns reference-only entities and
/// removes them through the plugin host API; locked-layer policy stays
/// enforced either way.
pub fn erase_entities_plugin(&mut self, handles: &[Handle]) {
self.remove_entities(handles, true, false);
}
/// Roll back entities created by the current command after a downstream
@@ -1069,10 +1078,15 @@ impl Scene {
/// The shared removal path keeps first-touch undo state, history objects,
/// groups, selection, and derived caches coherent.
pub(crate) fn rollback_new_entities(&mut self, handles: &[Handle]) {
self.remove_entities(handles, false);
self.remove_entities(handles, false, false);
}
fn remove_entities(&mut self, handles: &[Handle], respect_layer_locks: bool) {
fn remove_entities(
&mut self,
handles: &[Handle],
respect_layer_locks: bool,
respect_reference_only: bool,
) {
let erase_handles = self.handles_expanded_for_leader_annotations(handles);
// One pass over document.objects instead of a scan per erased handle.
@@ -1096,10 +1110,15 @@ impl Scene {
let mut hover_changed = false;
for &h in &erase_handles {
// Objects on a locked layer can't be erased.
// Objects on a locked layer can't be erased. Reference-only
// entities are plugin-owned: only the plugin-owned erase path
// may remove them.
if respect_layer_locks && self.is_layer_locked(h) {
continue;
}
if respect_reference_only && self.is_reference_only(h) {
continue;
}
let settings_handle = self.document.get_entity(h).and_then(|entity| match entity {
EntityType::Extended(extended) => match &extended.data {
codec::entities::ExtendedEntityData::SectionObject(data)
@@ -1125,6 +1144,12 @@ impl Scene {
let before = self.document.objects.get(&manager_handle).cloned();
self.record_undo_object_before(manager_handle, before);
}
// The designation set is scene state, not document state: a
// designated entity's set entry joins the same first-touch
// recording so undo restores entity and designation together.
if self.reference_only.is_marked(h) {
self.record_undo_reference_designation_before(h);
}
}
self.delete_solid_history(h);
if let Some(settings_handle) = settings_handle {
@@ -1143,6 +1168,9 @@ impl Scene {
}
self.remember_removed_cache_categories(h);
self.document.remove_entity_arc(h);
// The set entry dies with its entity (an absent entity is never
// reference-only). Undo restores it through the recorded channel.
self.reference_only.remove(h);
selection_changed |= self.selected.remove(&h);
self.selected_order.retain(|selected| *selected != h);
if self.hover_highlight == Some(h) {
+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,
+1
View File
@@ -4177,6 +4177,7 @@ impl Scene {
None,
None,
content_viewport,
self.reference_only.handles(),
);
for wire in &mut context_wires {
wire.color = tint;