feat(display): plugin entity-class labels in the panel, records, and automation

Resolve entities' XDATA class-identity keys through the live plugin manager
(new `src/plugin/entity_labels.rs`) and qualify display types as
"<Label> (<Native>)" wherever the bare native type is shown today.

- The single-entity Properties panel title (and the node graph, which shares
  the same computation) becomes e.g. "Linear Element (Line)". Host-owned
  classifications (xref, dynamic constraints, underlays, surfaces) keep the
  native parenthesized part, so nothing they tell the user is lost.
- The records API's `entities` collection: `display_type` carries the
  qualified label; the raw `type` / `record_type` fields stay native so
  existing scripts, type filters, and by-type grouping keep working.
- The automation entity summary gains a `display_type` field with the same
  semantics; `type` stays raw. Exposed by `query`, `user_select` sessions,
  and `get_selection`.

Semantics: the identity (a stable class KEY like "CVIFC-CLASS:LINE") lives
in the entity's XDATA and round-trips the drawing; the display LABELS live
in the declaring plugin's manifest. Keys only — no display text is ever
written to the file, and the label never governs behavior (display-only: it
does not affect snapping, selection, filtering, or grouping). Gating rides
live manager state at query time, so without a loaded declaring plugin every
surface here is byte-identical to today, immediately after any reopen, with
no re-assert pass. The command-line LIST output deliberately stays on the raw
dxf name. An app id claimed by more than one loaded plugin resolves through
the first-loaded declaration and debug-logs the ignored duplicate; a
prefix-only value, an unknown key, or an empty label mapping fall back to
the native type.

Tests: resolver core (known key, foreign app ignored, record without an
identity value ignored, prefix-only / unknown key / empty label fallback,
duplicate app id first-wins), the empty-manager gating end-to-end through
`with_manager`, the "<Label> (<Native>)" form, and the byte-identical
fallbacks asserted at the panel, records, and query layers for entities
stamped with a perfect identity record.
This commit is contained in:
Sebastian committed 2026-10-15 22:39:09 +02:00
1 parent 60eb7b086e
commit ab3e0723fd
6 files changed
+518 -36

No files matched your search

+57 -2
View File
@@ -257,12 +257,25 @@ pub(crate) fn entity_type_matches(entity: &codec::EntityType, requested: &str) -
/// One entity as JSON. Summary mode carries identity only, geometry adds the
/// entity's defining values, and full also includes its world bounds.
pub(crate) fn entity_json(e: &codec::EntityType, detail: &str) -> Value {
///
/// `custom_label` is the plugin-declared class label for the entity's XDATA
/// class identity, if a live declaring plugin provides one; it surfaces as
/// `display_type` ("`<Label>` (`<Native>`)"), while `type` stays the raw
/// native name so existing scripts and filters keep working.
pub(crate) fn entity_json(
e: &codec::EntityType,
detail: &str,
custom_label: Option<String>,
) -> Value {
use codec::EntityType as E;
let c = e.common();
let mut obj = json!({
"handle": format!("{:X}", c.handle.value()),
"type": crate::entities::names::ui_name(e),
"display_type": crate::plugin::entity_labels::display_type_title(
custom_label,
crate::entities::names::ui_name(e).to_string(),
),
"layer": c.layer,
});
if detail == "summary" {
@@ -1343,7 +1356,11 @@ impl OpenCADStudio {
if near.is_some() && nearest.is_none() {
continue;
}
let mut entity = entity_json(e, detail);
let mut entity = entity_json(
e,
detail,
crate::plugin::entity_labels::custom_entity_class_label(&self.disabled_plugins, e),
);
if let Some(nearest) = nearest {
let object = entity.as_object_mut().expect("entity JSON object");
object.insert("distance".into(), json!(nearest.distance));
@@ -4319,4 +4336,42 @@ mod tests {
.expect("second ready banner");
assert!(banner2.contains("ready"), "{banner2}");
}
#[test]
fn entity_json_display_type_is_native_without_a_label() {
let entity = codec::EntityType::Point(codec::entities::Point::new());
let json = super::entity_json(&entity, "summary", None);
assert_eq!(json["type"], "Point");
assert_eq!(json["display_type"], "Point");
}
#[test]
fn entity_json_display_type_qualifies_the_label() {
let entity = codec::EntityType::Point(codec::entities::Point::new());
let json = super::entity_json(&entity, "summary", Some("Survey Point".to_string()));
assert_eq!(json["type"], "Point");
assert_eq!(json["display_type"], "Survey Point (Point)");
}
#[test]
fn query_reports_native_display_type_for_plugin_classed_entities() {
// End-to-end through the automation query surface: an entity stamped
// with a plugin class identity keeps the native display while no
// declaring plugin is loaded (labels activate only through the live
// plugin manager, which the test harness does not populate).
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let mut entity = codec::EntityType::Point(codec::entities::Point::new());
let mut record = codec::xdata::ExtendedDataRecord::new("CVIFC");
record.add_value(codec::xdata::XDataValue::String(
"CVIFC-CLASS:POINTMARK".to_string(),
));
entity.common_mut().extended_data.add_record(record);
app.tabs[i].scene.add_entity(entity);
let query = app.automation_op(r#"{"op":"query","type":"Point"}"#);
assert_eq!(query["entities"][0]["type"], "Point");
assert_eq!(query["entities"][0]["display_type"], "Point");
}
}
+24 -7
View File
@@ -191,7 +191,16 @@ impl OpenCADStudio {
.scene
.document
.get_entity(*handle)
.map(|entity| entity_json(entity, &session.detail))
.map(|entity| {
entity_json(
entity,
&session.detail,
crate::plugin::entity_labels::custom_entity_class_label(
&self.disabled_plugins,
entity,
),
)
})
})
.collect();
let count = entities.len();
@@ -359,11 +368,11 @@ impl OpenCADStudio {
/// `get_selection` — read-only snapshot of the active tab's selection
/// (the read-back half of the `user_select` sampling flow). `handle`,
/// `type`, `layer` and `bounds` come straight from the `query` output so
/// clients share one parser; `text`/`value` expose the stripped and raw
/// strings for TEXT/MTEXT, and `block`/`position` identify an INSERT's
/// block definition and insertion point (null otherwise). Reads
/// `scene.selection` and never mutates it.
/// `type`, `display_type`, `layer` and `bounds` come straight from the
/// `query` output so clients share one parser; `text`/`value` expose the
/// stripped and raw strings for TEXT/MTEXT, and `block`/`position`
/// identify an INSERT's block definition and insertion point (null
/// otherwise). Reads `scene.selection` and never mutates it.
pub(in crate::app) fn control_get_selection(&self) -> Value {
let scene = &self.tabs[self.active_tab].scene;
let entities: Vec<Value> = scene
@@ -371,10 +380,18 @@ impl OpenCADStudio {
.iter()
.filter_map(|handle| {
scene.document.get_entity(*handle).map(|entity| {
let full = entity_json(entity, "full");
let full = entity_json(
entity,
"full",
crate::plugin::entity_labels::custom_entity_class_label(
&self.disabled_plugins,
entity,
),
);
json!({
"handle": full["handle"],
"type": full["type"],
"display_type": full["display_type"],
"layer": full["layer"],
"bounds": full["bounds"],
"text": full.get("text").cloned().unwrap_or(Value::Null),
+50
View File
@@ -1752,6 +1752,19 @@ impl OpenCADStudio {
}
None => title,
};
// A plugin-declared class identity (API v10) qualifies the title as
// "<Label> (<Native>)" while one of the live loaded plugins declares
// a label for this entity's XDATA class key; without one the title is
// exactly the native type. Display-only — the key round-trips the
// drawing, the label never does.
let title = crate::plugin::entity_labels::display_type_title(
crate::plugin::entity_labels::custom_entity_class_label(
&self.disabled_plugins,
source_entity,
),
title,
);
(sections, title)
}
@@ -5058,3 +5071,40 @@ mod aggregation_tests {
assert!(format!("{:?}", layer.value).contains("WALLS"));
}
}
#[cfg(test)]
mod custom_class_title_tests {
// Plugin-declared class labels (API v10) qualify the single-entity panel
// title as "<Label> (<Native>)" only while a declaring plugin is loaded.
// The test harness never loads plugins, so the observable behavior here is
// the gating: an entity stamped with a perfect identity record (registered
// app, class-prefix key) still shows today's exact native title. The
// "<Label> (<Native>)" form itself is covered by
// plugin::entity_labels::display_type_title, and the live-manager
// filtering by plugin::process::manager tests.
use crate::app::OpenCADStudio;
#[test]
fn single_entity_title_stays_native_without_a_loaded_plugin() {
let mut app = OpenCADStudio::new_for_test();
let i = app.active_tab;
let mut entity = codec::EntityType::Line(codec::entities::Line::from_points(
codec::types::Vector3::ZERO,
codec::types::Vector3::new(1.0, 0.0, 0.0),
));
let mut record = codec::xdata::ExtendedDataRecord::new("CVIFC");
record.add_value(codec::xdata::XDataValue::String(
"CVIFC-CLASS:LINE".to_string(),
));
entity.common_mut().extended_data.add_record(record);
let handle = app.tabs[i].scene.add_entity(entity);
let source = app.tabs[i]
.scene
.document
.get_entity(handle)
.expect("committed line");
let (_, title) = app.entity_property_sections(i, handle, source, &Vec::new(), None, 0);
assert_eq!(title, "Line");
}
}
+84 -27
View File
@@ -226,7 +226,11 @@ fn record(
value
}
fn entity_record(entity: &codec::EntityType, reference_only: bool) -> Result<Value, String> {
fn entity_record(
entity: &codec::EntityType,
reference_only: bool,
disabled_plugins: &rustc_hash::FxHashSet<String>,
) -> Result<Value, String> {
let (kind, properties) = enum_parts(entity)?;
let mut value = record(
"entities",
@@ -237,9 +241,18 @@ fn entity_record(entity: &codec::EntityType, reference_only: bool) -> Result<Val
properties,
);
let object = value.as_object_mut().expect("record object");
// `display_type` carries the plugin-declared class label
// ("<Label> (<Native>)") while a live declaring plugin claims the
// entity's XDATA class identity; without one it is byte-identical to the
// native ui_name. The raw `type` and `record_type` fields stay native so
// existing scripts and filters keep working.
let native = crate::entities::names::ui_name(entity);
object.insert(
"display_type".into(),
json!(crate::entities::names::ui_name(entity)),
json!(crate::plugin::entity_labels::display_type_title(
crate::plugin::entity_labels::custom_entity_class_label(disabled_plugins, entity),
native.to_string(),
)),
);
object.insert(
"record_type".into(),
@@ -308,12 +321,22 @@ fn map_records<T: Serialize>(
.collect()
}
fn collection_records(scene: &crate::scene::Scene, collection: &str) -> Result<Vec<Value>, String> {
fn collection_records(
scene: &crate::scene::Scene,
collection: &str,
disabled_plugins: &rustc_hash::FxHashSet<String>,
) -> Result<Vec<Value>, String> {
let document = &scene.document;
match collection {
"entities" => document
.entities()
.map(|entity| entity_record(entity, scene.is_reference_only(entity.common().handle)))
.map(|entity| {
entity_record(
entity,
scene.is_reference_only(entity.common().handle),
disabled_plugins,
)
})
.collect(),
"objects" => {
let mut objects: Vec<_> = document.objects.iter().collect();
@@ -1387,28 +1410,32 @@ impl OpenCADStudio {
}
let definitions = referenced_definitions(&root_type);
let runtime_schema = collection.and_then(|collection| {
collection_records(&self.tabs[self.active_tab].scene, collection)
.ok()?
.into_iter()
.find(|record| {
let type_matches = record["type"]
.as_str()
.is_some_and(|name| name.eq_ignore_ascii_case(requested_type))
|| collection_root_type(collection)
.is_some_and(|name| name.eq_ignore_ascii_case(requested_type));
type_matches
&& request["handle"].as_str().is_none_or(|handle| {
record["handle"].as_str().is_some_and(|actual| {
actual.eq_ignore_ascii_case(without_hex_prefix(handle))
})
collection_records(
&self.tabs[self.active_tab].scene,
collection,
&self.disabled_plugins,
)
.ok()?
.into_iter()
.find(|record| {
let type_matches = record["type"]
.as_str()
.is_some_and(|name| name.eq_ignore_ascii_case(requested_type))
|| collection_root_type(collection)
.is_some_and(|name| name.eq_ignore_ascii_case(requested_type));
type_matches
&& request["handle"].as_str().is_none_or(|handle| {
record["handle"].as_str().is_some_and(|actual| {
actual.eq_ignore_ascii_case(without_hex_prefix(handle))
})
&& request["name"].as_str().is_none_or(|name| {
record["name"]
.as_str()
.is_some_and(|actual| actual.eq_ignore_ascii_case(name))
})
})
.map(|record| runtime_value_schema(&record["properties"], collection, ""))
})
&& request["name"].as_str().is_none_or(|name| {
record["name"]
.as_str()
.is_some_and(|actual| actual.eq_ignore_ascii_case(name))
})
})
.map(|record| runtime_value_schema(&record["properties"], collection, ""))
});
json!({
"ok":true,
@@ -1438,13 +1465,13 @@ impl OpenCADStudio {
let mut records = Vec::new();
if collection == "all" {
for (name, _) in COLLECTIONS {
match collection_records(scene, name) {
match collection_records(scene, name, &self.disabled_plugins) {
Ok(mut values) => records.append(&mut values),
Err(error) => return failure("serialization_failed", error),
}
}
} else {
match collection_records(scene, collection) {
match collection_records(scene, collection, &self.disabled_plugins) {
Ok(values) => records = values,
Err(error) => return failure("unknown_collection", error),
}
@@ -1766,6 +1793,36 @@ mod tests {
assert_eq!(records["records"][0]["reference_only"], json!(false));
}
#[test]
fn entities_records_display_type_stays_native_without_a_loaded_plugin() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
// An entity stamped with a perfect plugin class identity (registered
// app, class-prefix key) still shows the native display while no
// declaring plugin is loaded — the gating is live manager state.
let mut entity = EntityType::Line(codec::entities::Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
));
let mut record = codec::xdata::ExtendedDataRecord::new("CVIFC");
record.add_value(codec::xdata::XDataValue::String(
"CVIFC-CLASS:LINE".to_string(),
));
entity.common_mut().extended_data.add_record(record);
app.tabs[i].scene.add_entity(entity);
let records = app.record_query(&json!({"collection":"entities"}));
assert_eq!(records["count"], 1);
assert_eq!(records["records"][0]["type"], json!("Line"));
assert_eq!(
records["records"][0]["display_type"],
json!("Line"),
"display_type is byte-identical to the native ui_name when no plugin is loaded"
);
}
#[test]
fn records_query_and_edit_every_mutable_record_family() {
let mut app = OpenCADStudio::new_for_test();