forked from 1b-agent/opencadstudio-upstream
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:
1 parent
60eb7b086e
commit
ab3e0723fd
6 files changed
+518
-36
No files matched your search
+57
-2
@@ -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
|
/// One entity as JSON. Summary mode carries identity only, geometry adds the
|
||||||
/// entity's defining values, and full also includes its world bounds.
|
/// 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;
|
use codec::EntityType as E;
|
||||||
let c = e.common();
|
let c = e.common();
|
||||||
let mut obj = json!({
|
let mut obj = json!({
|
||||||
"handle": format!("{:X}", c.handle.value()),
|
"handle": format!("{:X}", c.handle.value()),
|
||||||
"type": crate::entities::names::ui_name(e),
|
"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,
|
"layer": c.layer,
|
||||||
});
|
});
|
||||||
if detail == "summary" {
|
if detail == "summary" {
|
||||||
@@ -1343,7 +1356,11 @@ impl OpenCADStudio {
|
|||||||
if near.is_some() && nearest.is_none() {
|
if near.is_some() && nearest.is_none() {
|
||||||
continue;
|
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 {
|
if let Some(nearest) = nearest {
|
||||||
let object = entity.as_object_mut().expect("entity JSON object");
|
let object = entity.as_object_mut().expect("entity JSON object");
|
||||||
object.insert("distance".into(), json!(nearest.distance));
|
object.insert("distance".into(), json!(nearest.distance));
|
||||||
@@ -4319,4 +4336,42 @@ mod tests {
|
|||||||
.expect("second ready banner");
|
.expect("second ready banner");
|
||||||
assert!(banner2.contains("ready"), "{banner2}");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +191,16 @@ impl OpenCADStudio {
|
|||||||
.scene
|
.scene
|
||||||
.document
|
.document
|
||||||
.get_entity(*handle)
|
.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();
|
.collect();
|
||||||
let count = entities.len();
|
let count = entities.len();
|
||||||
@@ -359,11 +368,11 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
/// `get_selection` — read-only snapshot of the active tab's selection
|
/// `get_selection` — read-only snapshot of the active tab's selection
|
||||||
/// (the read-back half of the `user_select` sampling flow). `handle`,
|
/// (the read-back half of the `user_select` sampling flow). `handle`,
|
||||||
/// `type`, `layer` and `bounds` come straight from the `query` output so
|
/// `type`, `display_type`, `layer` and `bounds` come straight from the
|
||||||
/// clients share one parser; `text`/`value` expose the stripped and raw
|
/// `query` output so clients share one parser; `text`/`value` expose the
|
||||||
/// strings for TEXT/MTEXT, and `block`/`position` identify an INSERT's
|
/// stripped and raw strings for TEXT/MTEXT, and `block`/`position`
|
||||||
/// block definition and insertion point (null otherwise). Reads
|
/// identify an INSERT's block definition and insertion point (null
|
||||||
/// `scene.selection` and never mutates it.
|
/// otherwise). Reads `scene.selection` and never mutates it.
|
||||||
pub(in crate::app) fn control_get_selection(&self) -> Value {
|
pub(in crate::app) fn control_get_selection(&self) -> Value {
|
||||||
let scene = &self.tabs[self.active_tab].scene;
|
let scene = &self.tabs[self.active_tab].scene;
|
||||||
let entities: Vec<Value> = scene
|
let entities: Vec<Value> = scene
|
||||||
@@ -371,10 +380,18 @@ impl OpenCADStudio {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|handle| {
|
.filter_map(|handle| {
|
||||||
scene.document.get_entity(*handle).map(|entity| {
|
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!({
|
json!({
|
||||||
"handle": full["handle"],
|
"handle": full["handle"],
|
||||||
"type": full["type"],
|
"type": full["type"],
|
||||||
|
"display_type": full["display_type"],
|
||||||
"layer": full["layer"],
|
"layer": full["layer"],
|
||||||
"bounds": full["bounds"],
|
"bounds": full["bounds"],
|
||||||
"text": full.get("text").cloned().unwrap_or(Value::Null),
|
"text": full.get("text").cloned().unwrap_or(Value::Null),
|
||||||
|
|||||||
@@ -1752,6 +1752,19 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
None => title,
|
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)
|
(sections, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5058,3 +5071,40 @@ mod aggregation_tests {
|
|||||||
assert!(format!("{:?}", layer.value).contains("WALLS"));
|
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
@@ -226,7 +226,11 @@ fn record(
|
|||||||
value
|
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 (kind, properties) = enum_parts(entity)?;
|
||||||
let mut value = record(
|
let mut value = record(
|
||||||
"entities",
|
"entities",
|
||||||
@@ -237,9 +241,18 @@ fn entity_record(entity: &codec::EntityType, reference_only: bool) -> Result<Val
|
|||||||
properties,
|
properties,
|
||||||
);
|
);
|
||||||
let object = value.as_object_mut().expect("record object");
|
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(
|
object.insert(
|
||||||
"display_type".into(),
|
"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(
|
object.insert(
|
||||||
"record_type".into(),
|
"record_type".into(),
|
||||||
@@ -308,12 +321,22 @@ fn map_records<T: Serialize>(
|
|||||||
.collect()
|
.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;
|
let document = &scene.document;
|
||||||
match collection {
|
match collection {
|
||||||
"entities" => document
|
"entities" => document
|
||||||
.entities()
|
.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(),
|
.collect(),
|
||||||
"objects" => {
|
"objects" => {
|
||||||
let mut objects: Vec<_> = document.objects.iter().collect();
|
let mut objects: Vec<_> = document.objects.iter().collect();
|
||||||
@@ -1387,28 +1410,32 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
let definitions = referenced_definitions(&root_type);
|
let definitions = referenced_definitions(&root_type);
|
||||||
let runtime_schema = collection.and_then(|collection| {
|
let runtime_schema = collection.and_then(|collection| {
|
||||||
collection_records(&self.tabs[self.active_tab].scene, collection)
|
collection_records(
|
||||||
.ok()?
|
&self.tabs[self.active_tab].scene,
|
||||||
.into_iter()
|
collection,
|
||||||
.find(|record| {
|
&self.disabled_plugins,
|
||||||
let type_matches = record["type"]
|
)
|
||||||
.as_str()
|
.ok()?
|
||||||
.is_some_and(|name| name.eq_ignore_ascii_case(requested_type))
|
.into_iter()
|
||||||
|| collection_root_type(collection)
|
.find(|record| {
|
||||||
.is_some_and(|name| name.eq_ignore_ascii_case(requested_type));
|
let type_matches = record["type"]
|
||||||
type_matches
|
.as_str()
|
||||||
&& request["handle"].as_str().is_none_or(|handle| {
|
.is_some_and(|name| name.eq_ignore_ascii_case(requested_type))
|
||||||
record["handle"].as_str().is_some_and(|actual| {
|
|| collection_root_type(collection)
|
||||||
actual.eq_ignore_ascii_case(without_hex_prefix(handle))
|
.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"]
|
&& request["name"].as_str().is_none_or(|name| {
|
||||||
.as_str()
|
record["name"]
|
||||||
.is_some_and(|actual| actual.eq_ignore_ascii_case(name))
|
.as_str()
|
||||||
})
|
.is_some_and(|actual| actual.eq_ignore_ascii_case(name))
|
||||||
})
|
})
|
||||||
.map(|record| runtime_value_schema(&record["properties"], collection, ""))
|
})
|
||||||
|
.map(|record| runtime_value_schema(&record["properties"], collection, ""))
|
||||||
});
|
});
|
||||||
json!({
|
json!({
|
||||||
"ok":true,
|
"ok":true,
|
||||||
@@ -1438,13 +1465,13 @@ impl OpenCADStudio {
|
|||||||
let mut records = Vec::new();
|
let mut records = Vec::new();
|
||||||
if collection == "all" {
|
if collection == "all" {
|
||||||
for (name, _) in COLLECTIONS {
|
for (name, _) in COLLECTIONS {
|
||||||
match collection_records(scene, name) {
|
match collection_records(scene, name, &self.disabled_plugins) {
|
||||||
Ok(mut values) => records.append(&mut values),
|
Ok(mut values) => records.append(&mut values),
|
||||||
Err(error) => return failure("serialization_failed", error),
|
Err(error) => return failure("serialization_failed", error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match collection_records(scene, collection) {
|
match collection_records(scene, collection, &self.disabled_plugins) {
|
||||||
Ok(values) => records = values,
|
Ok(values) => records = values,
|
||||||
Err(error) => return failure("unknown_collection", error),
|
Err(error) => return failure("unknown_collection", error),
|
||||||
}
|
}
|
||||||
@@ -1766,6 +1793,36 @@ mod tests {
|
|||||||
assert_eq!(records["records"][0]["reference_only"], json!(false));
|
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]
|
#[test]
|
||||||
fn records_query_and_edit_every_mutable_record_family() {
|
fn records_query_and_edit_every_mutable_record_family() {
|
||||||
let mut app = OpenCADStudio::new_for_test();
|
let mut app = OpenCADStudio::new_for_test();
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
// Plugin-declared entity class labels (API v10).
|
||||||
|
//
|
||||||
|
// A plugin that stamps stable class-identity keys into entity XDATA (one
|
||||||
|
// string value per record, e.g. "CVIFC-CLASS:LINE") and declares an
|
||||||
|
// `EntityClasses` table through `BuiltinPlugin::entity_classes` gets those
|
||||||
|
// keys rendered as display labels while the plugin is loaded, e.g. the
|
||||||
|
// Properties panel title becomes "Linear Element (Line)". The identity keys
|
||||||
|
// round-trip the drawing; the labels never do — they are plugin-localizable
|
||||||
|
// display text resolved from the live manager state at query time.
|
||||||
|
//
|
||||||
|
// Gating is structural, not incidental: declarations are only ever read
|
||||||
|
// through the live plugin manager, so a plugin that is not installed,
|
||||||
|
// disabled, cap-excluded (`OCS_PLUGIN_MAX_API_VERSION`), failed at load, has
|
||||||
|
// been removed, or whose process has died contributes nothing — its entities
|
||||||
|
// display as their bare native type. No snapshot of declarations is cached.
|
||||||
|
|
||||||
|
use codec::EntityType;
|
||||||
|
use rustc_hash::FxHashSet;
|
||||||
|
|
||||||
|
// The wire declaration type and the resolution core exist only where the
|
||||||
|
// out-of-process plugin runtime does; the web build has no plugins at all.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use codec::xdata::ExtendedData;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use ocs_plugin_api::ribbon::owned::OwnedEntityClasses;
|
||||||
|
|
||||||
|
/// The display label a live, non-disabled plugin declares for `entity`'s
|
||||||
|
/// XDATA class identity, or `None` for the native fallback.
|
||||||
|
///
|
||||||
|
/// Scans the entity's XDATA records for one whose application name is claimed
|
||||||
|
/// by a live declaration and whose values carry a string starting with the
|
||||||
|
/// declared class prefix; the remainder is the class key. "Unknown key,
|
||||||
|
/// prefix-only, or empty label → `None`" is decided by whichever
|
||||||
|
/// class-identity value is found first: a real marker with no resolvable label
|
||||||
|
/// falls back to the native type rather than letting an unrelated record
|
||||||
|
/// win. Foreign-app records and records without a class-identity value are
|
||||||
|
/// skipped.
|
||||||
|
pub(crate) fn custom_entity_class_label(
|
||||||
|
disabled: &FxHashSet<String>,
|
||||||
|
entity: &EntityType,
|
||||||
|
) -> Option<String> {
|
||||||
|
let xdata = &entity.common().extended_data;
|
||||||
|
if xdata.records().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
// No external plugins exist in the web build; the native type stays.
|
||||||
|
let _ = disabled;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let declarations = crate::plugin::external::with_manager(|manager| {
|
||||||
|
manager.entity_class_declarations(|id| disabled.contains(id))
|
||||||
|
});
|
||||||
|
resolve_class_label(&declarations, xdata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure resolution core over a set of live `(plugin id, declaration)` pairs,
|
||||||
|
/// in load order. Kept separate from the live-manager plumbing so the
|
||||||
|
/// semantics test without spawning plugin processes.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub(crate) fn resolve_class_label(
|
||||||
|
declarations: &[(String, OwnedEntityClasses)],
|
||||||
|
xdata: &ExtendedData,
|
||||||
|
) -> Option<String> {
|
||||||
|
use codec::xdata::XDataValue;
|
||||||
|
|
||||||
|
for record in xdata.records() {
|
||||||
|
let Some(declaration) = declaration_for(declarations, &record.application_name) else {
|
||||||
|
continue; // Foreign application — ignored.
|
||||||
|
};
|
||||||
|
for value in &record.values {
|
||||||
|
let XDataValue::String(text) = value else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(key) = text.strip_prefix(&declaration.class_prefix) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// First class-identity value decides; an empty remainder (prefix
|
||||||
|
// only), unknown key, or empty label all mean the native type.
|
||||||
|
if key.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return match declaration.labels.get(key) {
|
||||||
|
Some(label) if !label.is_empty() => Some(label.clone()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first declaration (load order) claiming `app_id`. Later duplicates are
|
||||||
|
/// ignored and reported at debug level — an app id belongs to exactly one
|
||||||
|
/// loaded plugin.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn declaration_for<'a>(
|
||||||
|
declarations: &'a [(String, OwnedEntityClasses)],
|
||||||
|
app_id: &str,
|
||||||
|
) -> Option<&'a OwnedEntityClasses> {
|
||||||
|
let mut found = None;
|
||||||
|
for (plugin_id, classes) in declarations {
|
||||||
|
if classes.app_id != app_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if found.is_some() {
|
||||||
|
log::debug!(
|
||||||
|
"[plugin] entity-class app id {app_id} is declared by more than one loaded \
|
||||||
|
plugin; keeping the first declaration, ignoring plugin {plugin_id}"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
found = Some(classes);
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display form: `"<Label> (<Native>)"` when a plugin class label matched,
|
||||||
|
/// the native title unchanged otherwise. One rule for every surface — the
|
||||||
|
/// Properties panel title (native = translated `ui_name_or_class`), the
|
||||||
|
/// records API `display_type`, and the automation entity summary (native =
|
||||||
|
/// `ui_name`).
|
||||||
|
pub(crate) fn display_type_title(custom_label: Option<String>, native_title: String) -> String {
|
||||||
|
match custom_label {
|
||||||
|
Some(label) => format!("{label} ({native_title})"),
|
||||||
|
None => native_title,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use codec::entities::{Line, Point};
|
||||||
|
use codec::xdata::{ExtendedDataRecord, XDataValue};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn declaration(plugin_id: &str, app_id: &str) -> (String, OwnedEntityClasses) {
|
||||||
|
(
|
||||||
|
plugin_id.to_string(),
|
||||||
|
OwnedEntityClasses {
|
||||||
|
app_id: app_id.to_string(),
|
||||||
|
class_prefix: "CVIFC-CLASS:".to_string(),
|
||||||
|
labels: [
|
||||||
|
("LINE".to_string(), "Linear Element".to_string()),
|
||||||
|
(
|
||||||
|
"CIRCULARARC".to_string(),
|
||||||
|
"Circular Arc Element".to_string(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn line_with(tag: &str) -> EntityType {
|
||||||
|
let mut entity = EntityType::Line(Line::new());
|
||||||
|
let mut record = ExtendedDataRecord::new("CVIFC");
|
||||||
|
record.add_value(XDataValue::Integer32(1));
|
||||||
|
if !tag.is_empty() {
|
||||||
|
record.add_value(XDataValue::String(format!("CVIFC-CLASS:{tag}")));
|
||||||
|
}
|
||||||
|
entity.common_mut().extended_data.add_record(record);
|
||||||
|
entity
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn declared_app_with_known_key_resolves_the_label() {
|
||||||
|
let entity = line_with("LINE");
|
||||||
|
let xdata = &entity.common().extended_data;
|
||||||
|
assert_eq!(
|
||||||
|
resolve_class_label(&[declaration("opencad.civil_ifc", "CVIFC")], xdata),
|
||||||
|
Some("Linear Element".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreign_app_records_are_ignored() {
|
||||||
|
let mut entity = EntityType::Point(Point::new());
|
||||||
|
let mut record = ExtendedDataRecord::new("SOMEONE-ELSE");
|
||||||
|
record.add_value(XDataValue::String("CVIFC-CLASS:LINE".to_string()));
|
||||||
|
entity.common_mut().extended_data.add_record(record);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_class_label(
|
||||||
|
&[declaration("opencad.civil_ifc", "CVIFC")],
|
||||||
|
&entity.common().extended_data
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
"a record under a foreign app id must not resolve"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_without_identity_value_is_ignored_but_not_decisive() {
|
||||||
|
// First record: declared app, no class-identity value → skipped.
|
||||||
|
// Second record: declared app with a known key → resolves.
|
||||||
|
let mut entity = EntityType::Line(Line::new());
|
||||||
|
let mut markerless = ExtendedDataRecord::new("CVIFC");
|
||||||
|
markerless.add_value(XDataValue::Integer32(7));
|
||||||
|
entity.common_mut().extended_data.add_record(markerless);
|
||||||
|
let mut keyed = ExtendedDataRecord::new("CVIFC");
|
||||||
|
keyed.add_value(XDataValue::String("CVIFC-CLASS:LINE".to_string()));
|
||||||
|
entity.common_mut().extended_data.add_record(keyed);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_class_label(
|
||||||
|
&[declaration("opencad.civil_ifc", "CVIFC")],
|
||||||
|
&entity.common().extended_data
|
||||||
|
),
|
||||||
|
Some("Linear Element".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
// And with no key anywhere: native fallback.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_class_label(
|
||||||
|
&[declaration("opencad.civil_ifc", "CVIFC")],
|
||||||
|
&line_with("").common().extended_data
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefix_only_value_falls_back_to_native() {
|
||||||
|
let mut entity = EntityType::Line(Line::new());
|
||||||
|
let mut record = ExtendedDataRecord::new("CVIFC");
|
||||||
|
record.add_value(XDataValue::String("CVIFC-CLASS:".to_string()));
|
||||||
|
entity.common_mut().extended_data.add_record(record);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
resolve_class_label(
|
||||||
|
&[declaration("opencad.civil_ifc", "CVIFC")],
|
||||||
|
&entity.common().extended_data
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_key_falls_back_to_native() {
|
||||||
|
let entity = line_with("TANGENT");
|
||||||
|
let xdata = &entity.common().extended_data;
|
||||||
|
assert_eq!(
|
||||||
|
resolve_class_label(&[declaration("opencad.civil_ifc", "CVIFC")], xdata),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_label_falls_back_to_native() {
|
||||||
|
let mut empty_label = declaration("opencad.civil_ifc", "CVIFC");
|
||||||
|
empty_label
|
||||||
|
.1
|
||||||
|
.labels
|
||||||
|
.insert("LINE".to_string(), String::new());
|
||||||
|
let entity = line_with("LINE");
|
||||||
|
let xdata = &entity.common().extended_data;
|
||||||
|
assert_eq!(resolve_class_label(&[empty_label], xdata), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn later_duplicate_app_id_declaration_is_ignored() {
|
||||||
|
let mut second = declaration("other.plugin", "CVIFC");
|
||||||
|
second.1.labels.clear();
|
||||||
|
second
|
||||||
|
.1
|
||||||
|
.labels
|
||||||
|
.insert("LINE".to_string(), "Should Not Win".to_string());
|
||||||
|
let entity = line_with("LINE");
|
||||||
|
let xdata = &entity.common().extended_data;
|
||||||
|
assert_eq!(
|
||||||
|
resolve_class_label(&[declaration("opencad.civil_ifc", "CVIFC"), second], xdata),
|
||||||
|
Some("Linear Element".to_string()),
|
||||||
|
"the first loaded declaration for an app id wins"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_loaded_plugin_falls_back_to_native() {
|
||||||
|
// In the test build the live manager is empty (startup loading is
|
||||||
|
// compiled out), so the full path must yield the native fallback for
|
||||||
|
// an entity that carries a perfect identity record.
|
||||||
|
let entity = line_with("LINE");
|
||||||
|
assert_eq!(
|
||||||
|
custom_entity_class_label(&FxHashSet::default(), &entity),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn display_title_qualifies_native_with_label() {
|
||||||
|
assert_eq!(
|
||||||
|
display_type_title(Some("Linear Element".to_string()), "Line".to_string()),
|
||||||
|
"Linear Element (Line)"
|
||||||
|
);
|
||||||
|
assert_eq!(display_type_title(None, "Line".to_string()), "Line");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// from the application bundle or user plugins folder (see `external`).
|
// from the application bundle or user plugins folder (see `external`).
|
||||||
// See `docs/plugin-architecture.md`.
|
// See `docs/plugin-architecture.md`.
|
||||||
|
|
||||||
|
pub mod entity_labels;
|
||||||
pub mod external;
|
pub mod external;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
pub mod marketplace;
|
pub mod marketplace;
|
||||||
|
|||||||
Reference in new issue
Block a user