forked from 1b-agent/opencadstudio-upstream
Merge pull request #1828 from schoeller/xdata_entity_type_labels
This commit is contained in:
commit
a62598cd63
21 files changed
+1130
-53
No files matched your search
@@ -52,6 +52,9 @@
|
|||||||
* **Selection Event Streaming:** Automation clients now receive real-time selection change notifications. *(Contributed by [@YsDirector](https://github.com/YsDirector))*
|
* **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))*
|
* **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
|
### 🐛 Bug Fixes
|
||||||
|
|||||||
@@ -1263,7 +1263,7 @@ fn generate_version_info(out_dir: &Path) {
|
|||||||
"acadrust_version": opencadcodec.version.to_string(),
|
"acadrust_version": opencadcodec.version.to_string(),
|
||||||
"acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()),
|
"acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()),
|
||||||
"rustc_version": rustc_version,
|
"rustc_version": rustc_version,
|
||||||
"api_version": 9,
|
"api_version": 10,
|
||||||
"api_version_min_supported": 3,
|
"api_version_min_supported": 3,
|
||||||
"build_timestamp": build_timestamp,
|
"build_timestamp": build_timestamp,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||||
|
|
||||||
use crate::manifest::PluginManifest;
|
use crate::manifest::{EntityClasses, PluginManifest};
|
||||||
use crate::ribbon::CadModule;
|
use crate::ribbon::CadModule;
|
||||||
|
|
||||||
// Re-export the opencadcodec crate and the types that appear in the HostApi trait
|
// 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
|
/// Added in API v5. The runner only calls this for plugins that report API
|
||||||
/// major 5 or newer.
|
/// major 5 or newer.
|
||||||
fn on_load(&mut self, _host: &mut dyn HostApi) {}
|
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
|
/// A point-driven interactive command a plugin starts via
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn v4_additions_preserve_legacy_discriminants() {
|
fn v4_additions_preserve_legacy_discriminants() {
|
||||||
fn discriminant<T: serde::Serialize>(value: &T) -> u32 {
|
fn discriminant<T: serde::Serialize>(value: &T) -> u32 {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::host::{CommandSource, CommandStep, HostSettingValue};
|
use crate::host::{CommandSource, CommandStep, HostSettingValue};
|
||||||
use crate::manifest::ApiVersion;
|
use crate::manifest::ApiVersion;
|
||||||
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup};
|
use crate::ribbon::owned::{OwnedEntityClasses, OwnedPluginManifest, OwnedRibbonGroup};
|
||||||
|
|
||||||
pub use crate::host::PreviewWire;
|
pub use crate::host::PreviewWire;
|
||||||
pub use codec::xdata::{ExtendedDataRecord, XDataValue};
|
pub use codec::xdata::{ExtendedDataRecord, XDataValue};
|
||||||
@@ -307,6 +307,24 @@ impl From<&'static crate::manifest::PluginManifest> for OwnedPluginManifest {
|
|||||||
ribbon_order: m.ribbon_order,
|
ribbon_order: m.ribbon_order,
|
||||||
xdata_apps: m.xdata_apps.iter().map(|s| s.to_string()).collect(),
|
xdata_apps: m.xdata_apps.iter().map(|s| s.to_string()).collect(),
|
||||||
command_prefixes: m.command_prefixes.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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,11 @@
|
|||||||
/// `delete_xrecord` / `list_xrecords`). v9 appends the reference-only entity
|
/// `delete_xrecord` / `list_xrecords`). v9 appends the reference-only entity
|
||||||
/// designation (`HostApi::set_reference_only`): display-only geometry a plugin
|
/// designation (`HostApi::set_reference_only`): display-only geometry a plugin
|
||||||
/// still owns and refreshes while the built-in edit paths treat it like a
|
/// still owns and refreshes while the built-in edit paths treat it like a
|
||||||
/// locked layer.
|
/// locked layer. v10 appends `BuiltinPlugin::entity_classes`: plugin-declared
|
||||||
pub const API_VERSION: u32 = 9;
|
/// 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
|
/// 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
|
/// 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()
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::test_lock::ENV_LOCK;
|
use crate::test_lock::ENV_LOCK;
|
||||||
|
|||||||
@@ -1150,6 +1150,74 @@ fn generate_token() -> Result<String, PluginError> {
|
|||||||
Ok(out)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1352,6 +1420,7 @@ mod timeout_tests {
|
|||||||
ribbon_order: 0,
|
ribbon_order: 0,
|
||||||
xdata_apps: vec![],
|
xdata_apps: vec![],
|
||||||
command_prefixes: vec![],
|
command_prefixes: vec![],
|
||||||
|
entity_classes: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::host::{HostApi, HostNotification, PluginNotification};
|
use crate::host::{HostApi, HostNotification, PluginNotification};
|
||||||
use crate::process::{PluginError, PluginProcess};
|
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.
|
/// Callback the host installs to receive plugin-to-host notifications.
|
||||||
pub type NotificationHandler = Arc<dyn Fn(&str, Option<u64>, PluginNotification) + Send + Sync>;
|
pub type NotificationHandler = Arc<dyn Fn(&str, Option<u64>, PluginNotification) + Send + Sync>;
|
||||||
@@ -197,6 +197,29 @@ impl PluginManager {
|
|||||||
out
|
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
|
/// Drain any plugin→host requests that have arrived asynchronously across
|
||||||
/// all loaded plugins. Errors are logged; this is best-effort.
|
/// all loaded plugins. Errors are logged; this is best-effort.
|
||||||
pub fn drain_requests(
|
pub fn drain_requests(
|
||||||
@@ -358,4 +381,103 @@ mod tests {
|
|||||||
assert!(!result.handled);
|
assert!(!result.handled);
|
||||||
assert!(result.started.is_none());
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct OwnedPluginManifest {
|
pub struct OwnedPluginManifest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -133,6 +145,11 @@ pub struct OwnedPluginManifest {
|
|||||||
pub ribbon_order: i32,
|
pub ribbon_order: i32,
|
||||||
pub xdata_apps: Vec<String>,
|
pub xdata_apps: Vec<String>,
|
||||||
pub command_prefixes: 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 {
|
impl From<IconKind> for OwnedIconKind {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ use crate::ipc::protocol::{
|
|||||||
};
|
};
|
||||||
use crate::ipc::transport::{recv, send};
|
use crate::ipc::transport::{recv, send};
|
||||||
use crate::ipc::v4::client::{RunnerFrame, V4Client};
|
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.
|
/// 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 {
|
if version >= 5 {
|
||||||
invoke_on_load(&mut *plugin, &client, &interactive)?;
|
invoke_on_load(&mut *plugin, &client, &interactive)?;
|
||||||
}
|
}
|
||||||
run_v4(&mut *plugin, &interactive, client)
|
run_v4(&mut *plugin, &interactive, client, version)
|
||||||
} else {
|
} else {
|
||||||
let client = IpcClient::connect(socket_name)?;
|
let client = IpcClient::connect(socket_name)?;
|
||||||
eprintln!("[runner] connected to host");
|
eprintln!("[runner] connected to host");
|
||||||
@@ -106,6 +106,7 @@ fn run_v4(
|
|||||||
plugin: &mut dyn BuiltinPlugin,
|
plugin: &mut dyn BuiltinPlugin,
|
||||||
interactive: &InteractiveRegistry,
|
interactive: &InteractiveRegistry,
|
||||||
client: V4Client,
|
client: V4Client,
|
||||||
|
api_version: u32,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
loop {
|
loop {
|
||||||
// Drain any host notifications and dispatch them to the plugin's
|
// 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)) {
|
match client.recv_runner_frame_timeout(std::time::Duration::from_millis(50)) {
|
||||||
Ok(RunnerFrame::Request { id, payload }) => {
|
Ok(RunnerFrame::Request { id, payload }) => {
|
||||||
if let Some(resp) =
|
if let Some(resp) = handle_host_request_v4(
|
||||||
handle_host_request_v4(&mut *plugin, interactive, &client, id, payload)
|
&mut *plugin,
|
||||||
{
|
interactive,
|
||||||
|
&client,
|
||||||
|
id,
|
||||||
|
api_version,
|
||||||
|
payload,
|
||||||
|
) {
|
||||||
client.send_response(id, resp)?;
|
client.send_response(id, resp)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,12 +283,37 @@ fn handle_host_request_v4(
|
|||||||
interactive: &InteractiveRegistry,
|
interactive: &InteractiveRegistry,
|
||||||
client: &V4Client,
|
client: &V4Client,
|
||||||
id: u64,
|
id: u64,
|
||||||
|
api_version: u32,
|
||||||
req: HostRequest,
|
req: HostRequest,
|
||||||
) -> Option<HostResponse> {
|
) -> Option<HostResponse> {
|
||||||
match req {
|
match req {
|
||||||
HostRequest::GetManifest => {
|
HostRequest::GetManifest => {
|
||||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.manifest())) {
|
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(
|
Err(_) => Some(HostResponse::Error(
|
||||||
"plugin manifest() panicked".to_string(),
|
"plugin manifest() panicked".to_string(),
|
||||||
)),
|
)),
|
||||||
@@ -568,7 +599,7 @@ mod tests {
|
|||||||
code: "1+1".to_string(),
|
code: "1+1".to_string(),
|
||||||
tab_index: 0,
|
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 {
|
match resp {
|
||||||
Some(HostResponse::Error(msg)) => {
|
Some(HostResponse::Error(msg)) => {
|
||||||
assert!(msg.contains("does not support code execution"));
|
assert!(msg.contains("does not support code execution"));
|
||||||
@@ -577,4 +608,124 @@ mod tests {
|
|||||||
}
|
}
|
||||||
drop(host_stream);
|
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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +134,7 @@ pub trait BuiltinPlugin: Send + Sync {
|
|||||||
fn ribbon(&self) -> Box<dyn CadModule>; // the ribbon tab
|
fn ribbon(&self) -> Box<dyn CadModule>; // the ribbon tab
|
||||||
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
|
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
|
||||||
fn on_load(&mut self, host: &mut dyn HostApi) {}
|
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
|
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`.
|
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
|
## Building & distribution
|
||||||
|
|||||||
@@ -12,4 +12,8 @@ crate-type = ["cdylib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] }
|
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" }
|
||||||
@@ -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()`. |
|
| `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()`. |
|
| `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. |
|
| `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.
|
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
|
### Ribbon integration
|
||||||
|
|
||||||
The plugin registers one ribbon module (`SurveyTools`) with one group
|
The plugin registers one ribbon module (`SurveyTools`) with one group
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ license = "GPL-3.0-only"
|
|||||||
repository = "your-account/your-plugin-repo"
|
repository = "your-account/your-plugin-repo"
|
||||||
|
|
||||||
[opencad]
|
[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
|
# Replace both placeholders in the packaging step. The v1 template's
|
||||||
# release workflow contains a working example.
|
# release workflow contains a working example.
|
||||||
rustc_version = "__RUSTC_VERSION__"
|
rustc_version = "__RUSTC_VERSION__"
|
||||||
@@ -21,3 +23,7 @@ command_prefixes = [
|
|||||||
"COUNT_MARKED_SURVEY_POINTS",
|
"COUNT_MARKED_SURVEY_POINTS",
|
||||||
]
|
]
|
||||||
xdata_apps = ["SURVEYMARK"]
|
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.
|
||||||
@@ -8,7 +8,7 @@ use acadrust::xdata::{ExtendedDataRecord, XDataValue};
|
|||||||
use acadrust::{EntityType, Handle};
|
use acadrust::{EntityType, Handle};
|
||||||
use ocs_plugin_api::export_plugin;
|
use ocs_plugin_api::export_plugin;
|
||||||
use ocs_plugin_api::host::{BuiltinPlugin, HostApi, ReaderEntityKind};
|
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};
|
use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||||
|
|
||||||
static MANIFEST: PluginManifest = PluginManifest {
|
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;
|
struct PluginTemplateV2;
|
||||||
|
|
||||||
impl BuiltinPlugin for PluginTemplateV2 {
|
impl BuiltinPlugin for PluginTemplateV2 {
|
||||||
@@ -34,6 +46,12 @@ impl BuiltinPlugin for PluginTemplateV2 {
|
|||||||
&MANIFEST
|
&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> {
|
fn ribbon(&self) -> Box<dyn CadModule> {
|
||||||
Box::new(TemplateModule)
|
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) {
|
fn mark_point(host: &mut dyn HostApi, handle: Handle) {
|
||||||
let mut record = ExtendedDataRecord::new("SURVEYMARK");
|
let mut record = ExtendedDataRecord::new("SURVEYMARK");
|
||||||
record.add_value(XDataValue::Integer32(1));
|
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) {
|
if host.write_record(handle, record) {
|
||||||
host.push_info(&format!("Marked SURVEY point {handle}"));
|
host.push_info(&format!("Marked SURVEY point {handle}"));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+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),
|
||||||
|
|||||||
@@ -1742,6 +1742,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5048,3 +5061,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