Merge pull request #1828 from schoeller/xdata_entity_type_labels

This commit is contained in:
Hakan Seven committed 2026-10-16 01:12:36 +03:00
commit a62598cd63
21 files changed
+1130 -53

No files matched your search

+3
View File
@@ -52,6 +52,9 @@
* **Selection Event Streaming:** Automation clients now receive real-time selection change notifications. *(Contributed by [@YsDirector](https://github.com/YsDirector))*
* **Host API v8:** Added name-addressed `XRecord` CRUD operations on `HostApi`. *(Contributed by [@schoeller](https://github.com/schoeller))*
#### Plugins
* **Plugin-Declared Entity Class Labels (Host API v10):** Plugins can now give their XDATA-stamped entities human type names: the plugin stamps a stable class-identity key (e.g. `CVIFC-CLASS:LINE`) into entity XDATA and declares key → label mappings via the new `BuiltinPlugin::entity_classes()` manifest accessor; while the plugin is loaded, the Properties panel title and the records/automation `display_type` field show `<Label> (<Native>)` (e.g. "Linear Element (Line)"). Labels are display-only, resolved live from the loaded plugin set (not installed / disabled / dead process → bare native type), and never round-trip the drawing — identity keys do, so labels stay plugin-localizable without rewriting files. Raw schema fields (`type`, `dxf_name`, `record_type`) and the `LIST` command output are unchanged. *(Contributed by [@schoeller](https://github.com/schoeller))*
---
### 🐛 Bug Fixes
+1 -1
View File
@@ -1263,7 +1263,7 @@ fn generate_version_info(out_dir: &Path) {
"acadrust_version": opencadcodec.version.to_string(),
"acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()),
"rustc_version": rustc_version,
"api_version": 9,
"api_version": 10,
"api_version_min_supported": 3,
"build_timestamp": build_timestamp,
});
+16 -1
View File
@@ -16,7 +16,7 @@ use std::path::PathBuf;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::manifest::PluginManifest;
use crate::manifest::{EntityClasses, PluginManifest};
use crate::ribbon::CadModule;
// Re-export the opencadcodec crate and the types that appear in the HostApi trait
@@ -659,6 +659,21 @@ pub trait BuiltinPlugin: Send + Sync {
/// Added in API v5. The runner only calls this for plugins that report API
/// major 5 or newer.
fn on_load(&mut self, _host: &mut dyn HostApi) {}
/// Entity-class display declarations (API v10): the XDATA
/// class-identity keys the plugin stamps on entities, mapped to the
/// display labels the host shows while this plugin is loaded (e.g. the
/// Properties panel title becomes `"Linear Element (Line)"`). Display-only
/// — the labels never round-trip the drawing and never govern behavior;
/// entities carry only the stable identity keys in their XDATA.
///
/// The default (`None`) declares no entity classes. The runner only calls
/// this for plugins whose exported `ocs_plugin_api_version` symbol reports
/// API major 10 or newer, so older cdylibs are never touched past their
/// vtable.
fn entity_classes(&self) -> Option<&'static EntityClasses> {
None
}
}
/// A point-driven interactive command a plugin starts via
+65
View File
@@ -158,6 +158,71 @@ mod tests {
}
}
#[test]
fn registered_manifest_roundtrips_entity_classes_over_bincode() {
// The registration handshake is length-framed bincode, so the manifest
// and its API-v10 entity-class declaration must survive exactly that
// wire format.
let manifest = crate::ribbon::owned::OwnedPluginManifest {
id: "opencad.civil_ifc".to_string(),
name: "Civil IFC".to_string(),
version: "0.1.0".to_string(),
description: "test".to_string(),
api_version: 10,
ribbon_order: 50,
xdata_apps: vec!["CVIFC".to_string()],
command_prefixes: vec![],
entity_classes: Some(crate::ribbon::owned::OwnedEntityClasses {
app_id: "CVIFC".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(),
}),
};
let resp = HostResponse::Manifest(manifest);
let bytes = bincode::serialize(&resp).unwrap();
let got: HostResponse = bincode::deserialize(&bytes).unwrap();
match got {
HostResponse::Manifest(m) => {
let classes = m.entity_classes.expect("declaration survives");
assert_eq!(classes.app_id, "CVIFC");
assert_eq!(classes.class_prefix, "CVIFC-CLASS:");
assert_eq!(
classes.labels.get("LINE").map(String::as_str),
Some("Linear Element")
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn manifest_entity_classes_default_when_absent() {
// A manifest serialized before the field existed (an older runner)
// decodes with the declaration defaulting to `None`.
let older = r#"{
"id": "old.plugin",
"name": "Old",
"version": "0.1.0",
"description": "test",
"api_version": 9,
"ribbon_order": 0,
"xdata_apps": [],
"command_prefixes": []
}"#;
let m: crate::ribbon::owned::OwnedPluginManifest =
serde_json::from_str(older).expect("older manifest decodes");
assert_eq!(m.id, "old.plugin");
assert!(m.entity_classes.is_none());
}
#[test]
fn v4_additions_preserve_legacy_discriminants() {
fn discriminant<T: serde::Serialize>(value: &T) -> u32 {
+19 -1
View File
@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use crate::host::{CommandSource, CommandStep, HostSettingValue};
use crate::manifest::ApiVersion;
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup};
use crate::ribbon::owned::{OwnedEntityClasses, OwnedPluginManifest, OwnedRibbonGroup};
pub use crate::host::PreviewWire;
pub use codec::xdata::{ExtendedDataRecord, XDataValue};
@@ -307,6 +307,24 @@ impl From<&'static crate::manifest::PluginManifest> for OwnedPluginManifest {
ribbon_order: m.ribbon_order,
xdata_apps: m.xdata_apps.iter().map(|s| s.to_string()).collect(),
command_prefixes: m.command_prefixes.iter().map(|s| s.to_string()).collect(),
// The declaration does not live on the static manifest struct (its
// layout is frozen ABI, shared by pointer with older cdylibs); the
// runner folds the API-v10 trait accessor in after conversion.
entity_classes: None,
}
}
}
impl From<&'static crate::manifest::EntityClasses> for OwnedEntityClasses {
fn from(c: &'static crate::manifest::EntityClasses) -> Self {
Self {
app_id: c.app_id.to_string(),
class_prefix: c.class_prefix.to_string(),
labels: c
.labels
.iter()
.map(|(key, label)| (key.to_string(), label.to_string()))
.collect(),
}
}
}
+34 -2
View File
@@ -27,8 +27,11 @@
/// `delete_xrecord` / `list_xrecords`). v9 appends the reference-only entity
/// designation (`HostApi::set_reference_only`): display-only geometry a plugin
/// still owns and refreshes while the built-in edit paths treat it like a
/// locked layer.
pub const API_VERSION: u32 = 9;
/// locked layer. v10 appends `BuiltinPlugin::entity_classes`: plugin-declared
/// XDATA class-identity display labels, folded into the registered manifest.
/// Display-only; the host consults the declaration only while the declaring
/// plugin is loaded, and entities without one keep their native type names.
pub const API_VERSION: u32 = 10;
/// Oldest plugin API major the current host still loads. This keeps previously
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
@@ -77,6 +80,35 @@ pub fn host_accepts_plugin_version(plugin_major: u32) -> bool {
plugin_major >= API_VERSION_MIN_SUPPORTED && plugin_major <= effective_max_api_version()
}
/// A plugin's declaration of the entity classes it identifies via XDATA,
/// surfaced by the host as display-only type labels (the Properties panel
/// title and the automation `display_type` field).
///
/// The split is deliberate: the *identity* is a stable class KEY stamped into
/// the entity's XDATA (one string value per entity, e.g. `"MYAPP-CLASS:LINE"`)
/// and round-trips with the drawing; the *display labels* live in this
/// declaration, so they are plugin-localizable and fixable without rewriting
/// drawings. The declaration is consulted only while the declaring plugin is
/// loaded — a plugin that is not installed, disabled, failed to load, or whose
/// process has died contributes nothing, and its entities display as their
/// bare native type.
///
/// Declared through [`crate::host::BuiltinPlugin::entity_classes`] (API v10);
/// see `docs/plugin-architecture.md` for the full convention.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct EntityClasses {
/// XDATA application name whose records carry the class-identity keys
/// (e.g. `"CVIFC"`). Should be one of the manifest's `xdata_apps` so the
/// host knows the plugin writes under this id.
pub app_id: &'static str,
/// Prefix that marks a string XDATA value as a class-identity key
/// (e.g. `"CVIFC-CLASS:"`). The remainder of the value is the class key.
pub class_prefix: &'static str,
/// Class key → display label (e.g. `"LINE"` → `"Linear Element"`), in
/// declaration order. Display text never round-trips the drawing.
pub labels: &'static [(&'static str, &'static str)],
}
#[cfg(test)]
mod tests {
use crate::test_lock::ENV_LOCK;
+69
View File
@@ -1150,6 +1150,74 @@ fn generate_token() -> Result<String, PluginError> {
Ok(out)
}
/// In-crate test support: build a `PluginProcess` whose fields are otherwise
/// private to this module, so sibling test modules (`process::manager`) can
/// put one in a `PluginManager` without spawning a real runner.
#[cfg(all(test, feature = "host"))]
impl PluginProcess {
/// A process backed by a real sleeping child process and a local socket
/// pair, carrying the given manifest (no cdylib, no runner). The
/// runner-side peer socket is dropped immediately; nothing reads it.
pub(crate) fn fake(manifest: OwnedPluginManifest) -> Self {
use interprocess::local_socket::traits::Stream as StreamTrait;
static COUNTER: AtomicU64 = AtomicU64::new(0);
let name = format!(
"ocs_plugin_fake_process_test_{}_{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
);
let name_ref = name
.clone()
.to_ns_name::<GenericNamespaced>()
.expect("valid name");
let listener = ListenerOptions::new()
.name(name_ref)
.create_sync()
.expect("listener");
let client_name = name.clone();
let client_thread: std::thread::JoinHandle<Stream> = std::thread::spawn(move || {
StreamTrait::connect(client_name.to_ns_name::<GenericNamespaced>().unwrap())
.expect("connect")
});
let host_stream = listener.accept().expect("accept");
drop(client_thread.join().expect("client thread"));
Self {
stream: Mutex::new(Some(host_stream)),
v4: None,
child: Mutex::new(Some(sleepy_test_child())),
id: manifest.id.clone(),
manifest,
ribbon: vec![],
io_lines: Mutex::new(None),
}
}
}
/// A child that lives ~30 s — long enough to read as "alive", short enough to
/// never hang a leaked test. Shared by `PluginProcess::fake` and this module's
/// own tests.
#[cfg(all(test, feature = "host"))]
fn sleepy_test_child() -> Child {
#[cfg(windows)]
{
std::process::Command::new("cmd")
.arg("/c")
.arg("ping -n 30 127.0.0.1")
.stdout(std::process::Stdio::null())
.spawn()
.expect("spawn sleep")
}
#[cfg(not(windows))]
{
std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn sleep")
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1352,6 +1420,7 @@ mod timeout_tests {
ribbon_order: 0,
xdata_apps: vec![],
command_prefixes: vec![],
entity_classes: None,
}
}
+123 -1
View File
@@ -21,7 +21,7 @@ use std::time::Duration;
use crate::host::{HostApi, HostNotification, PluginNotification};
use crate::process::{PluginError, PluginProcess};
use crate::ribbon::owned::{to_shared_module, SharedCadModule};
use crate::ribbon::owned::{to_shared_module, OwnedEntityClasses, SharedCadModule};
/// Callback the host installs to receive plugin-to-host notifications.
pub type NotificationHandler = Arc<dyn Fn(&str, Option<u64>, PluginNotification) + Send + Sync>;
@@ -197,6 +197,29 @@ impl PluginManager {
out
}
/// Entity-class declarations of every alive, non-disabled plugin that
/// made one (API v10), in load order, as `(plugin id, declaration)`.
///
/// The host consults this at display time only — never cached — so the
/// result always mirrors the live loaded set: a plugin that failed to
/// load, is disabled, was removed, or whose process has died contributes
/// nothing, and entities it stamped fall back to their native type names.
pub fn entity_class_declarations<F: Fn(&str) -> bool>(
&self,
is_disabled: F,
) -> Vec<(String, OwnedEntityClasses)> {
let mut out = Vec::new();
for p in &self.plugins {
if is_disabled(p.process.id()) || !p.process.is_alive() {
continue;
}
if let Some(classes) = p.process.manifest().entity_classes.clone() {
out.push((p.process.id().to_string(), classes));
}
}
out
}
/// Drain any plugin→host requests that have arrived asynchronously across
/// all loaded plugins. Errors are logged; this is best-effort.
pub fn drain_requests(
@@ -358,4 +381,103 @@ mod tests {
assert!(!result.handled);
assert!(result.started.is_none());
}
fn manifest_with_entity_classes(id: &str) -> crate::ribbon::owned::OwnedPluginManifest {
crate::ribbon::owned::OwnedPluginManifest {
id: id.to_string(),
name: format!("{id} name"),
version: "0.1.0".to_string(),
description: "test".to_string(),
api_version: 10,
ribbon_order: 0,
xdata_apps: vec![],
command_prefixes: vec![],
entity_classes: Some(OwnedEntityClasses {
app_id: "CVIFC".to_string(),
class_prefix: "CVIFC-CLASS:".to_string(),
labels: [("LINE".to_string(), "Linear Element".to_string())]
.into_iter()
.collect(),
}),
}
}
fn plain_manifest(id: &str) -> crate::ribbon::owned::OwnedPluginManifest {
crate::ribbon::owned::OwnedPluginManifest {
id: id.to_string(),
name: format!("{id} name"),
version: "0.1.0".to_string(),
description: "test".to_string(),
api_version: 10,
ribbon_order: 0,
xdata_apps: vec![],
command_prefixes: vec![],
entity_classes: None,
}
}
#[test]
fn entity_class_declarations_skip_disabled_and_undeclaring_plugins() {
let mut manager = PluginManager::new();
manager.plugins.push(LoadedPlugin {
process: Arc::new(PluginProcess::fake(manifest_with_entity_classes(
"test.live",
))),
module: to_shared_module("test.live".into(), "Live".into(), vec![]),
});
manager.plugins.push(LoadedPlugin {
process: Arc::new(PluginProcess::fake(plain_manifest("test.plain"))),
module: to_shared_module("test.plain".into(), "Plain".into(), vec![]),
});
manager.plugins.push(LoadedPlugin {
process: Arc::new(PluginProcess::fake(manifest_with_entity_classes(
"test.off",
))),
module: to_shared_module("test.off".into(), "Off".into(), vec![]),
});
// Alive + enabled + declaring → reported, with the declaration intact.
let declarations = manager.entity_class_declarations(|id| id == "test.off");
assert_eq!(
declarations.len(),
1,
"only the live, enabled plugin reports"
);
assert_eq!(declarations[0].0, "test.live");
assert_eq!(declarations[0].1.app_id, "CVIFC");
assert_eq!(declarations[0].1.class_prefix, "CVIFC-CLASS:");
assert_eq!(
declarations[0].1.labels.get("LINE").map(String::as_str),
Some("Linear Element")
);
// Everything disabled → nothing contributes.
assert!(manager.entity_class_declarations(|_| true).is_empty());
}
#[test]
fn entity_class_declarations_revert_when_the_plugin_process_dies() {
let mut manager = PluginManager::new();
manager.plugins.push(LoadedPlugin {
process: Arc::new(PluginProcess::fake(manifest_with_entity_classes(
"test.live",
))),
module: to_shared_module("test.live".into(), "Live".into(), vec![]),
});
assert_eq!(
manager.entity_class_declarations(|_| false).len(),
1,
"declaration visible while the process is alive"
);
manager.plugins[0]
.process
.shutdown_and_wait(Duration::from_secs(5))
.expect("child shut down");
assert!(
manager.entity_class_declarations(|_| false).is_empty(),
"a dead plugin process must contribute no declarations"
);
}
}
+17
View File
@@ -123,6 +123,18 @@ fn collect_item_command_ids(item: &OwnedRibbonItem, out: &mut Vec<String>) {
}
}
/// Owned mirror of [`crate::manifest::EntityClasses`]: the entity-class
/// declaration as it travels on the registered manifest across the IPC wire.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OwnedEntityClasses {
/// XDATA application name whose records carry the class-identity keys.
pub app_id: String,
/// Prefix that marks a string XDATA value as a class-identity key.
pub class_prefix: String,
/// Class key → display label.
pub labels: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OwnedPluginManifest {
pub id: String,
@@ -133,6 +145,11 @@ pub struct OwnedPluginManifest {
pub ribbon_order: i32,
pub xdata_apps: Vec<String>,
pub command_prefixes: Vec<String>,
/// Entity-class display declarations (API v10), folded in by the runner
/// for plugins that declare them. `#[serde(default)]` keeps manifests
/// serialized before the field existed decoding as `None`.
#[serde(default)]
pub entity_classes: Option<OwnedEntityClasses>,
}
impl From<IconKind> for OwnedIconKind {
+158 -7
View File
@@ -29,7 +29,7 @@ use crate::ipc::protocol::{
};
use crate::ipc::transport::{recv, send};
use crate::ipc::v4::client::{RunnerFrame, V4Client};
use crate::ribbon::owned::OwnedRibbonGroup;
use crate::ribbon::owned::{OwnedEntityClasses, OwnedPluginManifest, OwnedRibbonGroup};
/// Entry point for the plugin runner child process.
///
@@ -59,7 +59,7 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box<dyn std::err
if version >= 5 {
invoke_on_load(&mut *plugin, &client, &interactive)?;
}
run_v4(&mut *plugin, &interactive, client)
run_v4(&mut *plugin, &interactive, client, version)
} else {
let client = IpcClient::connect(socket_name)?;
eprintln!("[runner] connected to host");
@@ -106,6 +106,7 @@ fn run_v4(
plugin: &mut dyn BuiltinPlugin,
interactive: &InteractiveRegistry,
client: V4Client,
api_version: u32,
) -> Result<(), Box<dyn std::error::Error>> {
loop {
// Drain any host notifications and dispatch them to the plugin's
@@ -119,9 +120,14 @@ fn run_v4(
match client.recv_runner_frame_timeout(std::time::Duration::from_millis(50)) {
Ok(RunnerFrame::Request { id, payload }) => {
if let Some(resp) =
handle_host_request_v4(&mut *plugin, interactive, &client, id, payload)
{
if let Some(resp) = handle_host_request_v4(
&mut *plugin,
interactive,
&client,
id,
api_version,
payload,
) {
client.send_response(id, resp)?;
}
}
@@ -277,12 +283,37 @@ fn handle_host_request_v4(
interactive: &InteractiveRegistry,
client: &V4Client,
id: u64,
api_version: u32,
req: HostRequest,
) -> Option<HostResponse> {
match req {
HostRequest::GetManifest => {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.manifest())) {
Ok(m) => Some(HostResponse::Manifest(m.into())),
Ok(m) => {
let mut manifest = OwnedPluginManifest::from(m);
// API v10: fold the plugin's entity-class declarations into
// the registered manifest. The call is guarded by the ABI
// version the cdylib was actually built against (the
// exported symbol, not the manifest's claim): an older
// cdylib's vtable predates the slot, so touching it would
// be undefined behavior. Its entities simply keep their
// native type names.
if api_version >= 10 {
let classes =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
plugin.entity_classes()
}));
manifest.entity_classes = match classes {
Ok(classes) => classes.map(OwnedEntityClasses::from),
Err(_) => {
return Some(HostResponse::Error(
"plugin entity_classes() panicked".to_string(),
))
}
};
}
Some(HostResponse::Manifest(manifest))
}
Err(_) => Some(HostResponse::Error(
"plugin manifest() panicked".to_string(),
)),
@@ -568,7 +599,7 @@ mod tests {
code: "1+1".to_string(),
tab_index: 0,
};
let resp = handle_host_request_v4(&mut plugin, &interactive, &client, 99, req);
let resp = handle_host_request_v4(&mut plugin, &interactive, &client, 99, 4, req);
match resp {
Some(HostResponse::Error(msg)) => {
assert!(msg.contains("does not support code execution"));
@@ -577,4 +608,124 @@ mod tests {
}
drop(host_stream);
}
static V10_MANIFEST: PluginManifest = PluginManifest {
id: "test.classed",
name: "Test Classed",
version: "0.1.0",
description: "test",
api_version: ApiVersion { major: 10 },
ribbon_order: 0,
xdata_apps: &["CVIFC"],
command_prefixes: &[],
};
static TEST_ENTITY_CLASSES: crate::manifest::EntityClasses = crate::manifest::EntityClasses {
app_id: "CVIFC",
class_prefix: "CVIFC-CLASS:",
labels: &[
("LINE", "Linear Element"),
("CIRCULARARC", "Circular Arc Element"),
],
};
struct ClassedPlugin;
impl BuiltinPlugin for ClassedPlugin {
fn manifest(&self) -> &'static PluginManifest {
&V10_MANIFEST
}
fn ribbon(&self) -> Box<dyn CadModule> {
Box::new(EmptyModule)
}
fn dispatch(&self, _host: &mut dyn HostApi, _cmd: &str) -> bool {
false
}
fn entity_classes(&self) -> Option<&'static crate::manifest::EntityClasses> {
Some(&TEST_ENTITY_CLASSES)
}
}
fn manifest_response_of(
plugin: &mut dyn BuiltinPlugin,
api_version: u32,
) -> Option<HostResponse> {
let (host_stream, runner_stream) = connect_pair();
let client = V4Client::from_stream_for_test(runner_stream);
let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new()));
let resp = handle_host_request_v4(
plugin,
&interactive,
&client,
99,
api_version,
HostRequest::GetManifest,
);
drop(host_stream);
resp
}
#[test]
fn get_manifest_folds_entity_classes_for_v10_plugin() {
let mut plugin = ClassedPlugin;
match manifest_response_of(&mut plugin, 10) {
Some(HostResponse::Manifest(manifest)) => {
let classes = manifest.entity_classes.expect("declaration folded in");
assert_eq!(classes.app_id, "CVIFC");
assert_eq!(classes.class_prefix, "CVIFC-CLASS:");
assert_eq!(
classes.labels.get("LINE").map(String::as_str),
Some("Linear Element")
);
assert_eq!(
classes.labels.get("CIRCULARARC").map(String::as_str),
Some("Circular Arc Element")
);
}
other => panic!("expected manifest response, got {other:?}"),
}
}
#[test]
fn get_manifest_leaves_entity_classes_default_below_v10() {
// A cdylib whose exported ABI symbol predates v10 must not have the
// trait slot called, even if the in-process plugin object happens to
// implement it: the declaration stays `None` (native display types).
let mut plugin = ClassedPlugin;
match manifest_response_of(&mut plugin, 9) {
Some(HostResponse::Manifest(manifest)) => {
assert!(
manifest.entity_classes.is_none(),
"entity classes must not be folded in for pre-v10 plugins"
);
}
other => panic!("expected manifest response, got {other:?}"),
}
}
#[test]
fn get_manifest_reports_entity_classes_panic() {
struct PanickingClassesPlugin;
impl BuiltinPlugin for PanickingClassesPlugin {
fn manifest(&self) -> &'static PluginManifest {
&V10_MANIFEST
}
fn ribbon(&self) -> Box<dyn CadModule> {
Box::new(EmptyModule)
}
fn dispatch(&self, _host: &mut dyn HostApi, _cmd: &str) -> bool {
false
}
fn entity_classes(&self) -> Option<&'static crate::manifest::EntityClasses> {
panic!("boom")
}
}
let mut plugin = PanickingClassesPlugin;
match manifest_response_of(&mut plugin, 10) {
Some(HostResponse::Error(msg)) => {
assert!(msg.contains("entity_classes() panicked"), "got: {msg}");
}
other => panic!("expected error response, got {other:?}"),
}
}
}
+52
View File
@@ -134,6 +134,7 @@ pub trait BuiltinPlugin: Send + Sync {
fn ribbon(&self) -> Box<dyn CadModule>; // the ribbon tab
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
fn on_load(&mut self, host: &mut dyn HostApi) {}
fn entity_classes(&self) -> Option<&'static EntityClasses> {} // API v10
}
```
@@ -296,6 +297,57 @@ Store domain data on entities as XDATA (under your `xdata_apps` ids), not in a
side database, so it round-trips through DWG/DXF. `write_record` also registers
the APPID. Document your schemas in the plugin's own `PLUGIN.md`.
### Entity class display labels (API v10)
A plugin can give its XDATA-stamped entities human type names in the host UI.
The split between *identity* (in the drawing) and *labels* (in the plugin) is
the point:
- **Identity** — the plugin stamps one string value into an entity's XDATA
record: `"<class_prefix><KEY>"`, e.g. `"CVIFC-CLASS:LINE"`. The key is
stable, meaningless to the host, and round-trips the DWG/DXF verbatim.
- **Labels** — the plugin declares a table and exposes it through
`BuiltinPlugin::entity_classes()`:
```rust
use ocs_plugin_api::manifest::EntityClasses;
static ENTITY_CLASSES: EntityClasses = EntityClasses {
app_id: "CVIFC", // one of the manifest's xdata_apps
class_prefix: "CVIFC-CLASS:",
labels: &[
("LINE", "Linear Element"),
("CIRCULARARC", "Circular Arc Element"),
],
};
impl BuiltinPlugin for MyPlugin {
fn entity_classes(&self) -> Option<&'static EntityClasses> {
Some(&ENTITY_CLASSES)
}
// ...
}
```
The host resolves them at display time only: the single-entity Properties
panel title becomes `"Linear Element (Line)"`, and the records/automation
`display_type` field takes the same form. The rule is display-only — a label
never governs behavior, and the raw schema fields (`type`, `dxf_name`,
`record_type`, the command-line `LIST` output) stay native so existing scripts
and filters keep working.
**Gating.** Declarations are consulted through the live plugin manager, never
cached: a plugin that is not installed, is disabled, is excluded by
`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 ("Line"), immediately, with no re-open needed. An `app_id` claimed
by more than one loaded plugin resolves through the first-loaded declaration;
later duplicates are ignored (debug-logged). A class key that is empty,
missing from the table, or mapped to an empty label falls back to the native
type. Because labels are display text resolved from the loaded plugin, they
are plugin-localizable and fixable without rewriting drawings — and
`plugin.toml` (the pre-load discovery file) deliberately does not carry them.
---
## Building & distribution
+5 -1
View File
@@ -12,4 +12,8 @@ crate-type = ["cdylib"]
[dependencies]
ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] }
acadrust = { git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "8a28c21" }
# Must match the host's opencadcodec pin exactly (its Cargo.toml's `codec`
# entry): the plugin's entities and the host's re-exports are the same types
# only when both resolve the same revision. The registration `rustc` /
# `acadrust_source` gates enforce this at load time too.
acadrust = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "278c49a" }
+19 -1
View File
@@ -20,7 +20,7 @@ rather than a runtime dependency of the host.
|---|---|
| `COUNT_SURVEY_POINTS` | Count point entities on the `SURVEY` layer via `document_reader()`. |
| `ADD_SURVEY_POINT` | Add a new point entity on the `SURVEY` layer via `host.add_entity()`. |
| `MARK_FIRST_SURVEY_POINT` | Read the first `SURVEY` point and mark it with an XDATA record. |
| `MARK_FIRST_SURVEY_POINT` | Read the first `SURVEY` point and mark it with an XDATA record (including the `SURVEY-CLASS:POINT` identity key). |
| `COUNT_MARKED_SURVEY_POINTS` | Count `SURVEY` points that carry the `SURVEYMARK` XDATA record. |
---
@@ -48,6 +48,24 @@ Each [`ReaderEntity`](ocs_plugin_api::host::ReaderEntity) exposes the entity
This is the primary read→write round-trip pattern the template demonstrates.
### Entity class display labels
The template also demonstrates the entity-class label convention (API v10):
- the plugin declares `ENTITY_CLASSES` and exposes it through
`BuiltinPlugin::entity_classes()`: app `SURVEYMARK`, prefix
`SURVEY-CLASS:`, and key → label mappings;
- `MARK_FIRST_SURVEY_POINT` stamps a `SURVEY-CLASS:POINT` string value into
the point's XDATA alongside the existing marker value.
While the plugin is loaded, the host shows marked points as
"Survey Point (Point)" in the Properties panel title and in the
`display_type` field of the records/automation APIs; without the plugin they
are plain "Point" again. Only the stable key round-trips the drawing — the
label text lives in the plugin and can be reworded or localized without
touching saved files. The label is display-only: it never governs snapping,
selection, or any other behavior.
### Ribbon integration
The plugin registers one ribbon module (`SurveyTools`) with one group
+7 -1
View File
@@ -8,7 +8,9 @@ license = "GPL-3.0-only"
repository = "your-account/your-plugin-repo"
[opencad]
api_version = 5
# Keep in sync with the Rust manifest's `ApiVersion::CURRENT` — this is the
# pre-load gate; the runner also checks the compiled-in symbol.
api_version = 10
# Replace both placeholders in the packaging step. The v1 template's
# release workflow contains a working example.
rustc_version = "__RUSTC_VERSION__"
@@ -21,3 +23,7 @@ command_prefixes = [
"COUNT_MARKED_SURVEY_POINTS",
]
xdata_apps = ["SURVEYMARK"]
# NOTE: the plugin's entity-class display labels (see `ENTITY_CLASSES` in
# src/lib.rs) travel on the registered manifest over IPC, not in this file —
# they only apply while the plugin is loaded, so plugin.toml (the pre-load
# discovery file) deliberately does not carry them.
+24 -1
View File
@@ -8,7 +8,7 @@ use acadrust::xdata::{ExtendedDataRecord, XDataValue};
use acadrust::{EntityType, Handle};
use ocs_plugin_api::export_plugin;
use ocs_plugin_api::host::{BuiltinPlugin, HostApi, ReaderEntityKind};
use ocs_plugin_api::manifest::{ApiVersion, PluginManifest};
use ocs_plugin_api::manifest::{ApiVersion, EntityClasses, PluginManifest};
use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
static MANIFEST: PluginManifest = PluginManifest {
@@ -27,6 +27,18 @@ static MANIFEST: PluginManifest = PluginManifest {
],
};
/// Entity-class display labels (API v10): entities carrying a
/// `SURVEY-CLASS:<key>` string in their SURVEYMARK XDATA display the mapped
/// label (e.g. "Survey Point (Point)") in the host's Properties panel and
/// `display_type` — *while this plugin is loaded*. The key is the only thing
/// stored in the drawing; the label text lives here, so it can be reworded or
/// localized without rewriting drawings.
static ENTITY_CLASSES: EntityClasses = EntityClasses {
app_id: "SURVEYMARK",
class_prefix: "SURVEY-CLASS:",
labels: &[("POINT", "Survey Point")],
};
struct PluginTemplateV2;
impl BuiltinPlugin for PluginTemplateV2 {
@@ -34,6 +46,12 @@ impl BuiltinPlugin for PluginTemplateV2 {
&MANIFEST
}
// API v10: expose the entity-class labels declared above. The default
// (not overriding this) declares none.
fn entity_classes(&self) -> Option<&'static EntityClasses> {
Some(&ENTITY_CLASSES)
}
fn ribbon(&self) -> Box<dyn CadModule> {
Box::new(TemplateModule)
}
@@ -106,6 +124,11 @@ fn first_survey_point_handle(host: &mut dyn HostApi) -> Option<Handle> {
fn mark_point(host: &mut dyn HostApi, handle: Handle) {
let mut record = ExtendedDataRecord::new("SURVEYMARK");
record.add_value(XDataValue::Integer32(1));
// Class-identity key for the entity-class labels declared above: the
// host shows marked points as "Survey Point (Point)" while this plugin is
// loaded, and as plain "Point" when it is not. Keys are stable identity —
// display text never goes into the drawing.
record.add_value(XDataValue::String("SURVEY-CLASS:POINT".to_string()));
if host.write_record(handle, record) {
host.push_info(&format!("Marked SURVEY point {handle}"));
} else {
+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
@@ -1742,6 +1742,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)
}
@@ -5048,3 +5061,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();
+302
View File
@@ -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");
}
}
+1
View File
@@ -2,6 +2,7 @@
// from the application bundle or user plugins folder (see `external`).
// See `docs/plugin-architecture.md`.
pub mod entity_labels;
pub mod external;
pub mod host;
pub mod marketplace;