Fix 4: shared-memory
This commit is contained in:
parent
a93d425002
commit
2b898d6f22
13 changed files with 894 additions and 92 deletions
|
|
@ -17,9 +17,11 @@ serde = { version = "1", features = ["derive"], optional = true }
|
||||||
bincode = { version = "1", optional = true }
|
bincode = { version = "1", optional = true }
|
||||||
thiserror = { version = "1", optional = true }
|
thiserror = { version = "1", optional = true }
|
||||||
libloading = { version = "0.8", optional = true }
|
libloading = { version = "0.8", optional = true }
|
||||||
|
memmap2 = { version = "0.9", optional = true }
|
||||||
|
rkyv = { version = "0.7", features = ["validation", "std"], optional = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# Enables the runtime host surface (`HostApi` trait) and the out-of-process
|
# Enables the runtime host surface (`HostApi` trait) and the out-of-process
|
||||||
# plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers
|
# plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers
|
||||||
# leave it off.
|
# leave it off.
|
||||||
host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading"]
|
host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv"]
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ pub trait HostApi {
|
||||||
// ── Document ────────────────────────────────────────────────────────────
|
// ── Document ────────────────────────────────────────────────────────────
|
||||||
fn document(&self) -> &CadDocument;
|
fn document(&self) -> &CadDocument;
|
||||||
fn document_mut(&mut self) -> &mut CadDocument;
|
fn document_mut(&mut self) -> &mut CadDocument;
|
||||||
|
|
||||||
/// Add an entity to the active document, returning its handle.
|
/// Add an entity to the active document, returning its handle.
|
||||||
fn add_entity(&mut self, entity: EntityType) -> Handle;
|
fn add_entity(&mut self, entity: EntityType) -> Handle;
|
||||||
/// Mark the scene geometry dirty so it is re-tessellated next frame.
|
/// Mark the scene geometry dirty so it is re-tessellated next frame.
|
||||||
|
|
@ -143,14 +144,143 @@ pub trait HostApi {
|
||||||
|
|
||||||
// ── Per-tab plugin state (object-safe; use the typed helpers below) ──────
|
// ── Per-tab plugin state (object-safe; use the typed helpers below) ──────
|
||||||
fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)>;
|
fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)>;
|
||||||
fn plugin_state_any_mut(&mut self, plugin_id: &str)
|
fn plugin_state_any_mut(&mut self, plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)>;
|
||||||
-> Option<&mut (dyn Any + Send + Sync)>;
|
|
||||||
/// Get the state for `plugin_id`, inserting `init()`'s result if absent.
|
/// Get the state for `plugin_id`, inserting `init()`'s result if absent.
|
||||||
fn ensure_plugin_state_any(
|
fn ensure_plugin_state_any(
|
||||||
&mut self,
|
&mut self,
|
||||||
plugin_id: &'static str,
|
plugin_id: &'static str,
|
||||||
init: &mut dyn FnMut() -> Box<dyn Any + Send + Sync>,
|
init: &mut dyn FnMut() -> Box<dyn Any + Send + Sync>,
|
||||||
) -> &mut (dyn Any + Send + Sync);
|
) -> &mut (dyn Any + Send + Sync);
|
||||||
|
|
||||||
|
// ── DocumentReader (added in API v3; appended at the end to keep vtable
|
||||||
|
// indices stable for API v2 plugins) ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// Read-only, zero-copy view of the active document. For out-of-process
|
||||||
|
/// plugins this is backed by host-owned shared memory; for in-process
|
||||||
|
/// plugins it wraps `document()`.
|
||||||
|
fn document_reader(&self) -> Box<dyn DocumentReader + '_>;
|
||||||
|
|
||||||
|
/// Open (or refresh) the host-side shared document view and return the
|
||||||
|
/// information the plugin needs to map it. In-process hosts implement this;
|
||||||
|
/// out-of-process plugin proxies return `None`.
|
||||||
|
fn document_view(&mut self) -> Option<crate::shm::DocumentViewInfo> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simplified, read-only entity kind exposed by [`DocumentReader`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ReaderEntityKind {
|
||||||
|
Point,
|
||||||
|
Line,
|
||||||
|
Circle,
|
||||||
|
Arc,
|
||||||
|
Polyline,
|
||||||
|
Text,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 3D point returned by [`DocumentReader`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct ReaderPoint {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub z: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A read-only view of one entity, borrowed from a [`DocumentReader`].
|
||||||
|
pub struct ReaderEntity<'a> {
|
||||||
|
/// Entity handle in the host document.
|
||||||
|
pub handle: Handle,
|
||||||
|
/// Simplified entity type.
|
||||||
|
pub kind: ReaderEntityKind,
|
||||||
|
/// Name of the layer the entity lives on.
|
||||||
|
pub layer_name: &'a str,
|
||||||
|
/// If the entity is a point, its coordinates.
|
||||||
|
pub point: Option<ReaderPoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read-only, zero-copy view of a CAD document.
|
||||||
|
///
|
||||||
|
/// For out-of-process plugins this is backed by host-owned shared memory. The
|
||||||
|
/// plugin receives only references into that mapping, so the document model is
|
||||||
|
/// not copied into the plugin's heap.
|
||||||
|
pub trait DocumentReader {
|
||||||
|
/// Total number of entities in the document.
|
||||||
|
fn entity_count(&self) -> usize;
|
||||||
|
|
||||||
|
/// Iterate over all entities without allocating a full `CadDocument`.
|
||||||
|
fn for_each_entity(&self, f: &mut dyn FnMut(ReaderEntity<'_>));
|
||||||
|
|
||||||
|
/// Look up a layer name by handle.
|
||||||
|
fn layer_name(&self, handle: Handle) -> Option<&str>;
|
||||||
|
|
||||||
|
/// Look up an APPID name by handle.
|
||||||
|
fn app_id_name(&self, handle: Handle) -> Option<&str>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReaderEntityKind {
|
||||||
|
/// Map a concrete `EntityType` to the simplified reader kind.
|
||||||
|
pub fn from_entity(entity: &EntityType) -> Self {
|
||||||
|
match entity {
|
||||||
|
EntityType::Point(_) => ReaderEntityKind::Point,
|
||||||
|
EntityType::Line(_) => ReaderEntityKind::Line,
|
||||||
|
EntityType::Circle(_) => ReaderEntityKind::Circle,
|
||||||
|
EntityType::Arc(_) => ReaderEntityKind::Arc,
|
||||||
|
EntityType::Polyline(_)
|
||||||
|
| EntityType::Polyline2D(_)
|
||||||
|
| EntityType::Polyline3D(_)
|
||||||
|
| EntityType::LwPolyline(_) => ReaderEntityKind::Polyline,
|
||||||
|
EntityType::Text(_) | EntityType::MText(_) => ReaderEntityKind::Text,
|
||||||
|
_ => ReaderEntityKind::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-process `DocumentReader` implementation that wraps a borrowed `CadDocument`.
|
||||||
|
pub struct CadDocumentReader<'a>(pub &'a CadDocument);
|
||||||
|
|
||||||
|
impl<'a> DocumentReader for CadDocumentReader<'a> {
|
||||||
|
fn entity_count(&self) -> usize {
|
||||||
|
self.0.entities().count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn for_each_entity(&self, f: &mut dyn FnMut(ReaderEntity<'_>)) {
|
||||||
|
for entity in self.0.entities() {
|
||||||
|
let kind = ReaderEntityKind::from_entity(entity);
|
||||||
|
let layer_name = entity.common().layer.as_str();
|
||||||
|
let point = match entity {
|
||||||
|
EntityType::Point(p) => Some(ReaderPoint {
|
||||||
|
x: p.location.x,
|
||||||
|
y: p.location.y,
|
||||||
|
z: p.location.z,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
f(ReaderEntity {
|
||||||
|
handle: entity.common().handle,
|
||||||
|
kind,
|
||||||
|
layer_name,
|
||||||
|
point,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layer_name(&self, handle: Handle) -> Option<&str> {
|
||||||
|
self.0
|
||||||
|
.layers
|
||||||
|
.iter()
|
||||||
|
.find(|layer| layer.handle == handle)
|
||||||
|
.map(|layer| layer.name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_id_name(&self, handle: Handle) -> Option<&str> {
|
||||||
|
self.0
|
||||||
|
.app_ids
|
||||||
|
.iter()
|
||||||
|
.find(|app_id| app_id.handle == handle)
|
||||||
|
.map(|app_id| app_id.name.as_str())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Typed read of per-tab plugin state stored under `plugin_id`.
|
/// Typed read of per-tab plugin state stored under `plugin_id`.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
use std::any::Any;
|
use std::any::Any;
|
||||||
use std::cell::{Cell, OnceCell, RefCell};
|
use std::cell::{Cell, OnceCell, RefCell};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use acadrust::xdata::ExtendedDataRecord;
|
use acadrust::xdata::ExtendedDataRecord;
|
||||||
|
|
@ -10,11 +11,12 @@ use acadrust::{CadDocument, EntityType, Handle};
|
||||||
use interprocess::local_socket::traits::Stream as StreamTrait;
|
use interprocess::local_socket::traits::Stream as StreamTrait;
|
||||||
use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName};
|
use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName};
|
||||||
|
|
||||||
use crate::host::{HostApi, InteractiveCommand};
|
use crate::host::{DocumentReader, HostApi, InteractiveCommand, ReaderEntity};
|
||||||
use crate::ipc::protocol::{
|
use crate::ipc::protocol::{
|
||||||
HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost,
|
HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost,
|
||||||
};
|
};
|
||||||
use crate::ipc::transport::{recv, send};
|
use crate::ipc::transport::{recv, send};
|
||||||
|
use crate::shm::{DocumentViewInfo, SharedDocumentReader};
|
||||||
|
|
||||||
/// Shared registry of active interactive commands, keyed by host-assigned id.
|
/// Shared registry of active interactive commands, keyed by host-assigned id.
|
||||||
pub type InteractiveRegistry = Rc<RefCell<HashMap<u64, Box<dyn InteractiveCommand>>>>;
|
pub type InteractiveRegistry = Rc<RefCell<HashMap<u64, Box<dyn InteractiveCommand>>>>;
|
||||||
|
|
@ -77,6 +79,9 @@ pub struct PluginHostApi {
|
||||||
/// stable references without leaking on every call. Each distinct record is
|
/// stable references without leaking on every call. Each distinct record is
|
||||||
/// leaked once per plugin dispatch/interactive session.
|
/// leaked once per plugin dispatch/interactive session.
|
||||||
record_cache: RefCell<HashMap<(Handle, String), &'static ExtendedDataRecord>>,
|
record_cache: RefCell<HashMap<(Handle, String), &'static ExtendedDataRecord>>,
|
||||||
|
/// Shared-memory document view information, lazily fetched on first
|
||||||
|
/// `document_reader()` access.
|
||||||
|
doc_view: RefCell<Option<DocumentViewInfo>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginHostApi {
|
impl PluginHostApi {
|
||||||
|
|
@ -88,6 +93,7 @@ impl PluginHostApi {
|
||||||
interactive,
|
interactive,
|
||||||
next_command_id: Cell::new(1),
|
next_command_id: Cell::new(1),
|
||||||
record_cache: RefCell::new(HashMap::new()),
|
record_cache: RefCell::new(HashMap::new()),
|
||||||
|
doc_view: RefCell::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,9 +185,7 @@ impl HostApi for PluginHostApi {
|
||||||
{
|
{
|
||||||
Ok(PluginResponse::Bool(b)) => {
|
Ok(PluginResponse::Bool(b)) => {
|
||||||
if b {
|
if b {
|
||||||
self.record_cache
|
self.record_cache.borrow_mut().remove(&(handle, app));
|
||||||
.borrow_mut()
|
|
||||||
.remove(&(handle, app));
|
|
||||||
}
|
}
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|
@ -221,10 +225,9 @@ impl HostApi for PluginHostApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_undo(&mut self, label: &str) {
|
fn push_undo(&mut self, label: &str) {
|
||||||
if let Err(e) = self
|
if let Err(e) = self.client.request(PluginRequest::PushUndo {
|
||||||
.client
|
label: label.to_string(),
|
||||||
.request(PluginRequest::PushUndo { label: label.to_string() })
|
}) {
|
||||||
{
|
|
||||||
eprintln!("[plugin] push_undo failed: {e}");
|
eprintln!("[plugin] push_undo failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -236,19 +239,28 @@ impl HostApi for PluginHostApi {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_info(&mut self, msg: &str) {
|
fn push_info(&mut self, msg: &str) {
|
||||||
if let Err(e) = self.client.request(PluginRequest::PushInfo(msg.to_string())) {
|
if let Err(e) = self
|
||||||
|
.client
|
||||||
|
.request(PluginRequest::PushInfo(msg.to_string()))
|
||||||
|
{
|
||||||
eprintln!("[plugin] push_info failed: {e}");
|
eprintln!("[plugin] push_info failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_output(&mut self, msg: &str) {
|
fn push_output(&mut self, msg: &str) {
|
||||||
if let Err(e) = self.client.request(PluginRequest::PushOutput(msg.to_string())) {
|
if let Err(e) = self
|
||||||
|
.client
|
||||||
|
.request(PluginRequest::PushOutput(msg.to_string()))
|
||||||
|
{
|
||||||
eprintln!("[plugin] push_output failed: {e}");
|
eprintln!("[plugin] push_output failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push_error(&mut self, msg: &str) {
|
fn push_error(&mut self, msg: &str) {
|
||||||
if let Err(e) = self.client.request(PluginRequest::PushError(msg.to_string())) {
|
if let Err(e) = self
|
||||||
|
.client
|
||||||
|
.request(PluginRequest::PushError(msg.to_string()))
|
||||||
|
{
|
||||||
eprintln!("[plugin] push_error failed: {e}");
|
eprintln!("[plugin] push_error failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -285,6 +297,54 @@ impl HostApi for PluginHostApi {
|
||||||
// state contract to work across processes.
|
// state contract to work across processes.
|
||||||
panic!("ensure_plugin_state is not supported for out-of-process plugins; keep state in the plugin crate")
|
panic!("ensure_plugin_state is not supported for out-of-process plugins; keep state in the plugin crate")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn document_reader(&self) -> Box<dyn DocumentReader + '_> {
|
||||||
|
{
|
||||||
|
let mut view = self.doc_view.borrow_mut();
|
||||||
|
if view.is_none() {
|
||||||
|
match self.client.request(PluginRequest::OpenDocumentView) {
|
||||||
|
Ok(PluginResponse::DocumentView { path, version }) => {
|
||||||
|
*view = Some(DocumentViewInfo { path, version });
|
||||||
|
}
|
||||||
|
Ok(other) => {
|
||||||
|
eprintln!("[plugin] unexpected OpenDocumentView response: {other:?}");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[plugin] OpenDocumentView request failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match self.doc_view.borrow().as_ref() {
|
||||||
|
Some(info) => match SharedDocumentReader::open(Path::new(&info.path)) {
|
||||||
|
Ok(reader) => Box::new(reader),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"[plugin] failed to open document view at {}: {e}",
|
||||||
|
info.path
|
||||||
|
);
|
||||||
|
Box::new(EmptyDocumentReader)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => Box::new(EmptyDocumentReader),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sentinel reader used when the shared-memory view could not be initialized.
|
||||||
|
struct EmptyDocumentReader;
|
||||||
|
|
||||||
|
impl DocumentReader for EmptyDocumentReader {
|
||||||
|
fn entity_count(&self) -> usize {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
fn for_each_entity(&self, _f: &mut dyn FnMut(ReaderEntity<'_>)) {}
|
||||||
|
fn layer_name(&self, _handle: Handle) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn app_id_name(&self, _handle: Handle) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(all(test, feature = "host"))]
|
#[cfg(all(test, feature = "host"))]
|
||||||
|
|
@ -292,8 +352,8 @@ mod tests {
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use acadrust::{EntityType, Handle};
|
|
||||||
use acadrust::entities::Point;
|
use acadrust::entities::Point;
|
||||||
|
use acadrust::{EntityType, Handle};
|
||||||
use interprocess::local_socket::{
|
use interprocess::local_socket::{
|
||||||
traits::{Listener, Stream as StreamTrait},
|
traits::{Listener, Stream as StreamTrait},
|
||||||
GenericNamespaced, ListenerOptions, Stream, ToNsName,
|
GenericNamespaced, ListenerOptions, Stream, ToNsName,
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,7 @@ mod tests {
|
||||||
.create_sync()
|
.create_sync()
|
||||||
.expect("create listener");
|
.expect("create listener");
|
||||||
let client = thread::spawn(move || {
|
let client = thread::spawn(move || {
|
||||||
StreamTrait::connect(name.to_ns_name::<GenericNamespaced>().unwrap())
|
StreamTrait::connect(name.to_ns_name::<GenericNamespaced>().unwrap()).expect("connect")
|
||||||
.expect("connect")
|
|
||||||
});
|
});
|
||||||
let server = listener.accept().expect("accept");
|
let server = listener.accept().expect("accept");
|
||||||
let client = client.join().expect("client thread");
|
let client = client.join().expect("client thread");
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ use crate::host::CommandStep;
|
||||||
use crate::manifest::ApiVersion;
|
use crate::manifest::ApiVersion;
|
||||||
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup};
|
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup};
|
||||||
|
|
||||||
pub use acadrust::{CadDocument, EntityType, Handle};
|
|
||||||
pub use acadrust::xdata::ExtendedDataRecord;
|
pub use acadrust::xdata::ExtendedDataRecord;
|
||||||
|
pub use acadrust::{CadDocument, EntityType, Handle};
|
||||||
|
|
||||||
/// Events the host forwards to an active plugin `InteractiveCommand`.
|
/// Events the host forwards to an active plugin `InteractiveCommand`.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -27,10 +27,19 @@ pub enum InteractiveEvent {
|
||||||
pub enum HostRequest {
|
pub enum HostRequest {
|
||||||
GetManifest,
|
GetManifest,
|
||||||
GetRibbon,
|
GetRibbon,
|
||||||
Dispatch { cmd: String },
|
Dispatch {
|
||||||
InteractiveEvent { command_id: u64, event: InteractiveEvent },
|
cmd: String,
|
||||||
GetPrompt { command_id: u64 },
|
},
|
||||||
NeedsEntityPick { command_id: u64 },
|
InteractiveEvent {
|
||||||
|
command_id: u64,
|
||||||
|
event: InteractiveEvent,
|
||||||
|
},
|
||||||
|
GetPrompt {
|
||||||
|
command_id: u64,
|
||||||
|
},
|
||||||
|
NeedsEntityPick {
|
||||||
|
command_id: u64,
|
||||||
|
},
|
||||||
Shutdown,
|
Shutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,13 +62,29 @@ pub enum PluginRequest {
|
||||||
PushError(String),
|
PushError(String),
|
||||||
AddEntity(EntityType),
|
AddEntity(EntityType),
|
||||||
BumpGeometry,
|
BumpGeometry,
|
||||||
ReadRecord { handle: Handle, app_name: String },
|
ReadRecord {
|
||||||
WriteRecord { handle: Handle, record: ExtendedDataRecord },
|
handle: Handle,
|
||||||
RemoveRecord { handle: Handle, app_name: String },
|
app_name: String,
|
||||||
PushUndo { label: String },
|
},
|
||||||
|
WriteRecord {
|
||||||
|
handle: Handle,
|
||||||
|
record: ExtendedDataRecord,
|
||||||
|
},
|
||||||
|
RemoveRecord {
|
||||||
|
handle: Handle,
|
||||||
|
app_name: String,
|
||||||
|
},
|
||||||
|
PushUndo {
|
||||||
|
label: String,
|
||||||
|
},
|
||||||
SetDirty,
|
SetDirty,
|
||||||
StartInteractive { command_id: u64 },
|
StartInteractive {
|
||||||
|
command_id: u64,
|
||||||
|
},
|
||||||
DocumentSnapshot,
|
DocumentSnapshot,
|
||||||
|
/// Ask the host to create/refresh a shared-memory document view and return
|
||||||
|
/// the file path + current version.
|
||||||
|
OpenDocumentView,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Responses the host sends back for `PluginRequest`.
|
/// Responses the host sends back for `PluginRequest`.
|
||||||
|
|
@ -71,6 +96,11 @@ pub enum PluginResponse {
|
||||||
Record(Option<ExtendedDataRecord>),
|
Record(Option<ExtendedDataRecord>),
|
||||||
Document(CadDocument),
|
Document(CadDocument),
|
||||||
Error(String),
|
Error(String),
|
||||||
|
/// Path to the memory-mapped file and the current snapshot version.
|
||||||
|
DocumentView {
|
||||||
|
path: String,
|
||||||
|
version: u64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Messages sent from the host to the plugin runner.
|
/// Messages sent from the host to the plugin runner.
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,7 @@ pub fn handle_plugin_request(
|
||||||
ReadRecord { handle, app_name } => {
|
ReadRecord { handle, app_name } => {
|
||||||
PluginResponse::Record(host.read_record(handle, &app_name).cloned())
|
PluginResponse::Record(host.read_record(handle, &app_name).cloned())
|
||||||
}
|
}
|
||||||
WriteRecord { handle, record } => {
|
WriteRecord { handle, record } => PluginResponse::Bool(host.write_record(handle, record)),
|
||||||
PluginResponse::Bool(host.write_record(handle, record))
|
|
||||||
}
|
|
||||||
RemoveRecord { handle, app_name } => {
|
RemoveRecord { handle, app_name } => {
|
||||||
PluginResponse::Bool(host.remove_record(handle, &app_name))
|
PluginResponse::Bool(host.remove_record(handle, &app_name))
|
||||||
}
|
}
|
||||||
|
|
@ -54,5 +52,12 @@ pub fn handle_plugin_request(
|
||||||
PluginResponse::Ok
|
PluginResponse::Ok
|
||||||
}
|
}
|
||||||
DocumentSnapshot => PluginResponse::Document(host.document().clone()),
|
DocumentSnapshot => PluginResponse::Document(host.document().clone()),
|
||||||
|
OpenDocumentView => match host.document_view() {
|
||||||
|
Some(info) => PluginResponse::DocumentView {
|
||||||
|
path: info.path,
|
||||||
|
version: info.version,
|
||||||
|
},
|
||||||
|
None => PluginResponse::Error("shared document view unavailable".to_string()),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,15 +32,17 @@ pub mod ipc;
|
||||||
#[cfg(feature = "host")]
|
#[cfg(feature = "host")]
|
||||||
pub mod process;
|
pub mod process;
|
||||||
|
|
||||||
|
/// Shared-memory document view — only built with the `host` feature.
|
||||||
|
#[cfg(feature = "host")]
|
||||||
|
pub mod shm;
|
||||||
|
|
||||||
/// Plugin runner implementation used by the host when it spawns itself in
|
/// Plugin runner implementation used by the host when it spawns itself in
|
||||||
/// runner mode — only built with the `host` feature.
|
/// runner mode — only built with the `host` feature.
|
||||||
#[cfg(feature = "host")]
|
#[cfg(feature = "host")]
|
||||||
pub mod runner;
|
pub mod runner;
|
||||||
|
|
||||||
pub use manifest::{ApiVersion, PluginManifest, API_VERSION};
|
pub use manifest::{ApiVersion, PluginManifest, API_VERSION, API_VERSION_MIN_SUPPORTED};
|
||||||
pub use ribbon::{
|
pub use ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef};
|
||||||
CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(feature = "host")]
|
#[cfg(feature = "host")]
|
||||||
pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess};
|
pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess};
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,15 @@
|
||||||
/// Host plugin API version. Bump when the host runtime surface breaks
|
/// Host plugin API version. Bump when the host runtime surface breaks
|
||||||
/// compatibility. v2 added `HostApi::start_interactive`. v3 changes
|
/// compatibility. v2 added `HostApi::start_interactive`. v3 changes
|
||||||
/// `document()` / `document_mut()` to local cached copies for out-of-process
|
/// `document()` / `document_mut()` to local cached copies for out-of-process
|
||||||
/// plugins.
|
/// plugins and appends `document_reader` / `document_view` at the end of the
|
||||||
|
/// vtable so API v2 plugins keep working.
|
||||||
pub const API_VERSION: u32 = 3;
|
pub const API_VERSION: u32 = 3;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// current `HostApi` trait.
|
||||||
|
pub const API_VERSION_MIN_SUPPORTED: u32 = 2;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub struct ApiVersion {
|
pub struct ApiVersion {
|
||||||
pub major: u32,
|
pub major: u32,
|
||||||
|
|
@ -14,8 +20,11 @@ pub struct ApiVersion {
|
||||||
impl ApiVersion {
|
impl ApiVersion {
|
||||||
pub const CURRENT: Self = Self { major: API_VERSION };
|
pub const CURRENT: Self = Self { major: API_VERSION };
|
||||||
|
|
||||||
pub fn is_compatible_with(host: ApiVersion) -> bool {
|
/// True when this plugin version can run on `host`. A plugin is compatible
|
||||||
Self::CURRENT.major == host.major
|
/// with any host whose API major is the same or newer (new host methods are
|
||||||
|
/// appended at the end of the vtable, so old plugins ignore them).
|
||||||
|
pub fn is_compatible_with(&self, host: ApiVersion) -> bool {
|
||||||
|
self.major <= host.major
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,11 +39,21 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn same_major_is_compatible() {
|
fn same_major_is_compatible() {
|
||||||
assert!(ApiVersion::is_compatible_with(ApiVersion::CURRENT));
|
assert!(ApiVersion::CURRENT.is_compatible_with(ApiVersion::CURRENT));
|
||||||
assert!(!ApiVersion::is_compatible_with(ApiVersion {
|
assert!(!ApiVersion::CURRENT.is_compatible_with(ApiVersion {
|
||||||
|
major: API_VERSION - 1,
|
||||||
|
}));
|
||||||
|
// Forward compatibility: a plugin compiled today runs on a future host
|
||||||
|
// that only appends new vtable entries.
|
||||||
|
assert!(ApiVersion::CURRENT.is_compatible_with(ApiVersion {
|
||||||
major: API_VERSION + 1,
|
major: API_VERSION + 1,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn api_v2_plugin_runs_on_api_v3_host() {
|
||||||
|
assert!(ApiVersion { major: 2 }.is_compatible_with(ApiVersion { major: 3 }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Static metadata every plugin supplies at registration time.
|
/// Static metadata every plugin supplies at registration time.
|
||||||
|
|
|
||||||
|
|
@ -61,22 +61,21 @@ pub struct PluginProcess {
|
||||||
|
|
||||||
impl PluginProcess {
|
impl PluginProcess {
|
||||||
/// Spawn the plugin cdylib in a separate process and connect to it.
|
/// Spawn the plugin cdylib in a separate process and connect to it.
|
||||||
pub fn spawn(
|
pub fn spawn(cdylib_path: &Path, host: &mut dyn HostApi) -> Result<Self, PluginError> {
|
||||||
cdylib_path: &Path,
|
|
||||||
host: &mut dyn HostApi,
|
|
||||||
) -> Result<Self, PluginError> {
|
|
||||||
let socket_name = generate_socket_name();
|
let socket_name = generate_socket_name();
|
||||||
let socket_name_ref: interprocess::local_socket::Name = socket_name
|
let socket_name_ref: interprocess::local_socket::Name = socket_name
|
||||||
.clone()
|
.clone()
|
||||||
.to_ns_name::<GenericNamespaced>()
|
.to_ns_name::<GenericNamespaced>()
|
||||||
.expect("valid namespaced name");
|
.expect("valid namespaced name");
|
||||||
let runner_path = runner_executable()?;
|
let runner_path = runner_executable()?;
|
||||||
eprintln!("[plugin] spawning runner {} for {}", runner_path.display(), cdylib_path.display());
|
eprintln!(
|
||||||
|
"[plugin] spawning runner {} for {}",
|
||||||
|
runner_path.display(),
|
||||||
|
cdylib_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
// Create the listener before spawning so the runner can connect immediately.
|
// Create the listener before spawning so the runner can connect immediately.
|
||||||
let listener = ListenerOptions::new()
|
let listener = ListenerOptions::new().name(socket_name_ref).create_sync()?;
|
||||||
.name(socket_name_ref)
|
|
||||||
.create_sync()?;
|
|
||||||
|
|
||||||
let mut child = Command::new(&runner_path)
|
let mut child = Command::new(&runner_path)
|
||||||
.arg("--ocs-plugin-runner")
|
.arg("--ocs-plugin-runner")
|
||||||
|
|
@ -387,11 +386,15 @@ fn distinct_runner_path(host: &Path) -> PathBuf {
|
||||||
let mut runner = host.as_os_str().to_owned();
|
let mut runner = host.as_os_str().to_owned();
|
||||||
if let Some(ext) = host.extension().and_then(|s| s.to_str()) {
|
if let Some(ext) = host.extension().and_then(|s| s.to_str()) {
|
||||||
let base = host.file_stem().unwrap_or_default();
|
let base = host.file_stem().unwrap_or_default();
|
||||||
runner = std::ffi::OsString::from(format!("{}-plugin-runner.{}", base.to_string_lossy(), ext));
|
runner =
|
||||||
|
std::ffi::OsString::from(format!("{}-plugin-runner.{}", base.to_string_lossy(), ext));
|
||||||
} else {
|
} else {
|
||||||
runner.push("-plugin-runner");
|
runner.push("-plugin-runner");
|
||||||
}
|
}
|
||||||
let mut path = host.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
|
let mut path = host
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("."))
|
||||||
|
.to_path_buf();
|
||||||
path.push(runner);
|
path.push(runner);
|
||||||
path
|
path
|
||||||
}
|
}
|
||||||
|
|
@ -411,7 +414,10 @@ mod tests {
|
||||||
fn distinct_runner_path_appends_suffix() {
|
fn distinct_runner_path_appends_suffix() {
|
||||||
let host = PathBuf::from("/app/OpenCADStudio.exe");
|
let host = PathBuf::from("/app/OpenCADStudio.exe");
|
||||||
let runner = distinct_runner_path(&host);
|
let runner = distinct_runner_path(&host);
|
||||||
assert_eq!(runner, PathBuf::from("/app/OpenCADStudio-plugin-runner.exe"));
|
assert_eq!(
|
||||||
|
runner,
|
||||||
|
PathBuf::from("/app/OpenCADStudio-plugin-runner.exe")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,9 @@ pub struct DispatchResult {
|
||||||
impl PluginManager {
|
impl PluginManager {
|
||||||
/// Create an empty manager.
|
/// Create an empty manager.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { plugins: Vec::new() }
|
Self {
|
||||||
|
plugins: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn `cdylib_path` as a separate plugin process, build its ribbon
|
/// Spawn `cdylib_path` as a separate plugin process, build its ribbon
|
||||||
|
|
@ -165,6 +167,20 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dispatch_with_no_plugins_is_not_handled() {
|
fn dispatch_with_no_plugins_is_not_handled() {
|
||||||
|
struct EmptyReader;
|
||||||
|
impl crate::host::DocumentReader for EmptyReader {
|
||||||
|
fn entity_count(&self) -> usize {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
fn for_each_entity(&self, _f: &mut dyn FnMut(crate::host::ReaderEntity<'_>)) {}
|
||||||
|
fn layer_name(&self, _handle: acadrust::Handle) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn app_id_name(&self, _handle: acadrust::Handle) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct DummyHost;
|
struct DummyHost;
|
||||||
impl HostApi for DummyHost {
|
impl HostApi for DummyHost {
|
||||||
fn tab_index(&self) -> usize {
|
fn tab_index(&self) -> usize {
|
||||||
|
|
@ -176,6 +192,9 @@ mod tests {
|
||||||
fn document_mut(&mut self) -> &mut acadrust::CadDocument {
|
fn document_mut(&mut self) -> &mut acadrust::CadDocument {
|
||||||
panic!("not used")
|
panic!("not used")
|
||||||
}
|
}
|
||||||
|
fn document_reader(&self) -> Box<dyn crate::host::DocumentReader + '_> {
|
||||||
|
Box::new(EmptyReader)
|
||||||
|
}
|
||||||
fn add_entity(&mut self, _entity: acadrust::EntityType) -> acadrust::Handle {
|
fn add_entity(&mut self, _entity: acadrust::EntityType) -> acadrust::Handle {
|
||||||
panic!("not used")
|
panic!("not used")
|
||||||
}
|
}
|
||||||
|
|
@ -194,11 +213,7 @@ mod tests {
|
||||||
) -> bool {
|
) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
fn remove_record(
|
fn remove_record(&mut self, _handle: acadrust::Handle, _app_name: &str) -> bool {
|
||||||
&mut self,
|
|
||||||
_handle: acadrust::Handle,
|
|
||||||
_app_name: &str,
|
|
||||||
) -> bool {
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
fn push_undo(&mut self, _label: &str) {}
|
fn push_undo(&mut self, _label: &str) {}
|
||||||
|
|
@ -206,11 +221,7 @@ mod tests {
|
||||||
fn push_info(&mut self, _msg: &str) {}
|
fn push_info(&mut self, _msg: &str) {}
|
||||||
fn push_output(&mut self, _msg: &str) {}
|
fn push_output(&mut self, _msg: &str) {}
|
||||||
fn push_error(&mut self, _msg: &str) {}
|
fn push_error(&mut self, _msg: &str) {}
|
||||||
fn start_interactive(
|
fn start_interactive(&mut self, _command: Box<dyn crate::host::InteractiveCommand>) {}
|
||||||
&mut self,
|
|
||||||
_command: Box<dyn crate::host::InteractiveCommand>,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
fn plugin_state_any(
|
fn plugin_state_any(
|
||||||
&self,
|
&self,
|
||||||
_plugin_id: &str,
|
_plugin_id: &str,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,9 @@ pub enum OwnedRibbonItem {
|
||||||
row2: Vec<OwnedToolDef>,
|
row2: Vec<OwnedToolDef>,
|
||||||
row3: Vec<OwnedToolDef>,
|
row3: Vec<OwnedToolDef>,
|
||||||
},
|
},
|
||||||
PropertiesGroup { match_prop: OwnedToolDef },
|
PropertiesGroup {
|
||||||
|
match_prop: OwnedToolDef,
|
||||||
|
},
|
||||||
StyleComboGroup {
|
StyleComboGroup {
|
||||||
style_key: StyleKey,
|
style_key: StyleKey,
|
||||||
combo_id: String,
|
combo_id: String,
|
||||||
|
|
@ -114,17 +116,34 @@ impl From<RibbonItem> for OwnedRibbonItem {
|
||||||
match item {
|
match item {
|
||||||
RibbonItem::Tool(t) => OwnedRibbonItem::Tool(t.into()),
|
RibbonItem::Tool(t) => OwnedRibbonItem::Tool(t.into()),
|
||||||
RibbonItem::LargeTool(t) => OwnedRibbonItem::LargeTool(t.into()),
|
RibbonItem::LargeTool(t) => OwnedRibbonItem::LargeTool(t.into()),
|
||||||
RibbonItem::Dropdown { id, icon, items, default } => OwnedRibbonItem::Dropdown {
|
RibbonItem::Dropdown {
|
||||||
|
id,
|
||||||
|
icon,
|
||||||
|
items,
|
||||||
|
default,
|
||||||
|
} => OwnedRibbonItem::Dropdown {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
icon: icon.into(),
|
icon: icon.into(),
|
||||||
items: items.into_iter().map(|(a, b, i)| (a.to_string(), b.to_string(), i.into())).collect(),
|
items: items
|
||||||
|
.into_iter()
|
||||||
|
.map(|(a, b, i)| (a.to_string(), b.to_string(), i.into()))
|
||||||
|
.collect(),
|
||||||
default: default.to_string(),
|
default: default.to_string(),
|
||||||
},
|
},
|
||||||
RibbonItem::LargeDropdown { id, label, icon, items, default } => OwnedRibbonItem::LargeDropdown {
|
RibbonItem::LargeDropdown {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
items,
|
||||||
|
default,
|
||||||
|
} => OwnedRibbonItem::LargeDropdown {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
label: label.to_string(),
|
label: label.to_string(),
|
||||||
icon: icon.into(),
|
icon: icon.into(),
|
||||||
items: items.into_iter().map(|(a, b, i)| (a.to_string(), b.to_string(), i.into())).collect(),
|
items: items
|
||||||
|
.into_iter()
|
||||||
|
.map(|(a, b, i)| (a.to_string(), b.to_string(), i.into()))
|
||||||
|
.collect(),
|
||||||
default: default.to_string(),
|
default: default.to_string(),
|
||||||
},
|
},
|
||||||
RibbonItem::LayerComboGroup { row2, row3 } => OwnedRibbonItem::LayerComboGroup {
|
RibbonItem::LayerComboGroup { row2, row3 } => OwnedRibbonItem::LayerComboGroup {
|
||||||
|
|
@ -134,11 +153,19 @@ impl From<RibbonItem> for OwnedRibbonItem {
|
||||||
RibbonItem::PropertiesGroup { match_prop } => OwnedRibbonItem::PropertiesGroup {
|
RibbonItem::PropertiesGroup { match_prop } => OwnedRibbonItem::PropertiesGroup {
|
||||||
match_prop: match_prop.into(),
|
match_prop: match_prop.into(),
|
||||||
},
|
},
|
||||||
RibbonItem::StyleComboGroup { style_key, combo_id, manager_cmd, rows } => OwnedRibbonItem::StyleComboGroup {
|
RibbonItem::StyleComboGroup {
|
||||||
|
style_key,
|
||||||
|
combo_id,
|
||||||
|
manager_cmd,
|
||||||
|
rows,
|
||||||
|
} => OwnedRibbonItem::StyleComboGroup {
|
||||||
style_key,
|
style_key,
|
||||||
combo_id: combo_id.to_string(),
|
combo_id: combo_id.to_string(),
|
||||||
manager_cmd: manager_cmd.map(|s| s.to_string()),
|
manager_cmd: manager_cmd.map(|s| s.to_string()),
|
||||||
rows: rows.into_iter().map(|r| r.into_iter().map(Into::into).collect()).collect(),
|
rows: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.into_iter().map(Into::into).collect())
|
||||||
|
.collect(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -149,17 +176,46 @@ impl OwnedRibbonItem {
|
||||||
match self {
|
match self {
|
||||||
OwnedRibbonItem::Tool(t) => RibbonItem::Tool(t.to_static()),
|
OwnedRibbonItem::Tool(t) => RibbonItem::Tool(t.to_static()),
|
||||||
OwnedRibbonItem::LargeTool(t) => RibbonItem::LargeTool(t.to_static()),
|
OwnedRibbonItem::LargeTool(t) => RibbonItem::LargeTool(t.to_static()),
|
||||||
OwnedRibbonItem::Dropdown { id, icon, items, default } => RibbonItem::Dropdown {
|
OwnedRibbonItem::Dropdown {
|
||||||
|
id,
|
||||||
|
icon,
|
||||||
|
items,
|
||||||
|
default,
|
||||||
|
} => RibbonItem::Dropdown {
|
||||||
id: &*Box::leak(id.into_boxed_str()),
|
id: &*Box::leak(id.into_boxed_str()),
|
||||||
icon: icon.to_static(),
|
icon: icon.to_static(),
|
||||||
items: items.into_iter().map(|(a, b, i)| (&*Box::leak(a.into_boxed_str()), &*Box::leak(b.into_boxed_str()), i.to_static())).collect(),
|
items: items
|
||||||
|
.into_iter()
|
||||||
|
.map(|(a, b, i)| {
|
||||||
|
(
|
||||||
|
&*Box::leak(a.into_boxed_str()),
|
||||||
|
&*Box::leak(b.into_boxed_str()),
|
||||||
|
i.to_static(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
default: &*Box::leak(default.into_boxed_str()),
|
default: &*Box::leak(default.into_boxed_str()),
|
||||||
},
|
},
|
||||||
OwnedRibbonItem::LargeDropdown { id, label, icon, items, default } => RibbonItem::LargeDropdown {
|
OwnedRibbonItem::LargeDropdown {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
items,
|
||||||
|
default,
|
||||||
|
} => RibbonItem::LargeDropdown {
|
||||||
id: &*Box::leak(id.into_boxed_str()),
|
id: &*Box::leak(id.into_boxed_str()),
|
||||||
label: &*Box::leak(label.into_boxed_str()),
|
label: &*Box::leak(label.into_boxed_str()),
|
||||||
icon: icon.to_static(),
|
icon: icon.to_static(),
|
||||||
items: items.into_iter().map(|(a, b, i)| (&*Box::leak(a.into_boxed_str()), &*Box::leak(b.into_boxed_str()), i.to_static())).collect(),
|
items: items
|
||||||
|
.into_iter()
|
||||||
|
.map(|(a, b, i)| {
|
||||||
|
(
|
||||||
|
&*Box::leak(a.into_boxed_str()),
|
||||||
|
&*Box::leak(b.into_boxed_str()),
|
||||||
|
i.to_static(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
default: &*Box::leak(default.into_boxed_str()),
|
default: &*Box::leak(default.into_boxed_str()),
|
||||||
},
|
},
|
||||||
OwnedRibbonItem::LayerComboGroup { row2, row3 } => RibbonItem::LayerComboGroup {
|
OwnedRibbonItem::LayerComboGroup { row2, row3 } => RibbonItem::LayerComboGroup {
|
||||||
|
|
@ -169,11 +225,19 @@ impl OwnedRibbonItem {
|
||||||
OwnedRibbonItem::PropertiesGroup { match_prop } => RibbonItem::PropertiesGroup {
|
OwnedRibbonItem::PropertiesGroup { match_prop } => RibbonItem::PropertiesGroup {
|
||||||
match_prop: match_prop.to_static(),
|
match_prop: match_prop.to_static(),
|
||||||
},
|
},
|
||||||
OwnedRibbonItem::StyleComboGroup { style_key, combo_id, manager_cmd, rows } => RibbonItem::StyleComboGroup {
|
OwnedRibbonItem::StyleComboGroup {
|
||||||
|
style_key,
|
||||||
|
combo_id,
|
||||||
|
manager_cmd,
|
||||||
|
rows,
|
||||||
|
} => RibbonItem::StyleComboGroup {
|
||||||
style_key,
|
style_key,
|
||||||
combo_id: &*Box::leak(combo_id.into_boxed_str()),
|
combo_id: &*Box::leak(combo_id.into_boxed_str()),
|
||||||
manager_cmd: manager_cmd.map(|s| &*Box::leak(s.into_boxed_str())),
|
manager_cmd: manager_cmd.map(|s| &*Box::leak(s.into_boxed_str())),
|
||||||
rows: rows.into_iter().map(|r| r.into_iter().map(|t| t.to_static()).collect()).collect(),
|
rows: rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.into_iter().map(|t| t.to_static()).collect())
|
||||||
|
.collect(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -198,11 +262,7 @@ impl OwnedRibbonGroup {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert owned ribbon groups into a `CadModule` by leaking the strings once.
|
/// Convert owned ribbon groups into a `CadModule` by leaking the strings once.
|
||||||
pub fn to_module(
|
pub fn to_module(id: String, title: String, groups: Vec<OwnedRibbonGroup>) -> Box<dyn CadModule> {
|
||||||
id: String,
|
|
||||||
title: String,
|
|
||||||
groups: Vec<OwnedRibbonGroup>,
|
|
||||||
) -> Box<dyn CadModule> {
|
|
||||||
struct M {
|
struct M {
|
||||||
id: &'static str,
|
id: &'static str,
|
||||||
title: &'static str,
|
title: &'static str,
|
||||||
|
|
@ -224,7 +284,10 @@ pub fn to_module(
|
||||||
Box::new(M {
|
Box::new(M {
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
groups: groups.into_iter().map(OwnedRibbonGroup::to_static).collect(),
|
groups: groups
|
||||||
|
.into_iter()
|
||||||
|
.map(OwnedRibbonGroup::to_static)
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ use std::rc::Rc;
|
||||||
|
|
||||||
use crate::host::{BuiltinPlugin, InteractiveCommand};
|
use crate::host::{BuiltinPlugin, InteractiveCommand};
|
||||||
use crate::ipc::client::{InteractiveRegistry, IpcClient, PluginHostApi};
|
use crate::ipc::client::{InteractiveRegistry, IpcClient, PluginHostApi};
|
||||||
use crate::ipc::protocol::{HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost};
|
use crate::ipc::protocol::{
|
||||||
|
HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost,
|
||||||
|
};
|
||||||
use crate::ipc::transport::{recv, send};
|
use crate::ipc::transport::{recv, send};
|
||||||
use crate::ribbon::owned::OwnedRibbonGroup;
|
use crate::ribbon::owned::OwnedRibbonGroup;
|
||||||
|
|
||||||
|
|
@ -86,14 +88,13 @@ fn handle_host_request(
|
||||||
Err(_) => HostResponse::Error("plugin dispatch panicked".to_string()),
|
Err(_) => HostResponse::Error("plugin dispatch panicked".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HostRequest::InteractiveEvent {
|
HostRequest::InteractiveEvent { command_id, event } => {
|
||||||
command_id,
|
|
||||||
event,
|
|
||||||
} => {
|
|
||||||
let step = {
|
let step = {
|
||||||
let mut registry = interactive.borrow_mut();
|
let mut registry = interactive.borrow_mut();
|
||||||
let Some(cmd) = registry.get_mut(&command_id) else {
|
let Some(cmd) = registry.get_mut(&command_id) else {
|
||||||
return HostResponse::Error(format!("unknown interactive command {command_id}"));
|
return HostResponse::Error(format!(
|
||||||
|
"unknown interactive command {command_id}"
|
||||||
|
));
|
||||||
};
|
};
|
||||||
let cmd_ref: &mut dyn InteractiveCommand = cmd.as_mut();
|
let cmd_ref: &mut dyn InteractiveCommand = cmd.as_mut();
|
||||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match event {
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match event {
|
||||||
|
|
@ -126,7 +127,9 @@ fn handle_host_request(
|
||||||
let result = {
|
let result = {
|
||||||
let registry = interactive.borrow();
|
let registry = interactive.borrow();
|
||||||
registry.get(&command_id).map(|cmd| {
|
registry.get(&command_id).map(|cmd| {
|
||||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd.needs_object_pick()))
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
cmd.needs_object_pick()
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
|
|
@ -142,18 +145,17 @@ fn handle_host_request(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn load_plugin(
|
unsafe fn load_plugin(path: &Path) -> Result<Box<dyn BuiltinPlugin>, Box<dyn std::error::Error>> {
|
||||||
path: &Path,
|
|
||||||
) -> Result<Box<dyn BuiltinPlugin>, Box<dyn std::error::Error>> {
|
|
||||||
let lib = libloading::Library::new(path)?;
|
let lib = libloading::Library::new(path)?;
|
||||||
|
|
||||||
let version: libloading::Symbol<extern "C" fn() -> u32> = lib
|
let version: libloading::Symbol<extern "C" fn() -> u32> = lib
|
||||||
.get(b"ocs_plugin_api_version")
|
.get(b"ocs_plugin_api_version")
|
||||||
.map_err(|_| "missing ocs_plugin_api_version symbol")?;
|
.map_err(|_| "missing ocs_plugin_api_version symbol")?;
|
||||||
let v = version();
|
let v = version();
|
||||||
if v != crate::API_VERSION {
|
if v < crate::API_VERSION_MIN_SUPPORTED || v > crate::API_VERSION {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"API version {v} != host {}",
|
"API version {v} is incompatible (host supports {}-{})",
|
||||||
|
crate::API_VERSION_MIN_SUPPORTED,
|
||||||
crate::API_VERSION
|
crate::API_VERSION
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
|
|
|
||||||
473
crates/ocs_plugin_api/src/shm.rs
Normal file
473
crates/ocs_plugin_api/src/shm.rs
Normal file
|
|
@ -0,0 +1,473 @@
|
||||||
|
//! Shared-memory document view for out-of-process plugins.
|
||||||
|
//!
|
||||||
|
//! The host owns a memory-mapped file that contains a small, read-only,
|
||||||
|
//! rkyv-serialized view of the active document. The plugin maps the same file
|
||||||
|
//! read-only and reads entity/layer data directly from the mapping without
|
||||||
|
//! copying the full `CadDocument` into its own address space.
|
||||||
|
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU32, AtomicU64, AtomicU8, AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
use acadrust::{CadDocument, EntityType, Handle};
|
||||||
|
use memmap2::{Mmap, MmapMut};
|
||||||
|
use rkyv::{check_archived_root, to_bytes, Archive, Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::host::{DocumentReader, ReaderEntity, ReaderEntityKind, ReaderPoint};
|
||||||
|
|
||||||
|
/// Magic number identifying a valid control page.
|
||||||
|
const CONTROL_MAGIC: u32 = 0x4F_43_53_44; // "OCSD"
|
||||||
|
|
||||||
|
/// Size of the control region at the start of the mapping. Must be enough for
|
||||||
|
/// `ControlPage` and aligned to a typical page boundary so the snapshot segments
|
||||||
|
/// that follow are naturally aligned for rkyv.
|
||||||
|
const CONTROL_SIZE: usize = 4096;
|
||||||
|
|
||||||
|
/// Information sent to the plugin so it can open the shared mapping.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DocumentViewInfo {
|
||||||
|
/// Absolute path to the memory-mapped file.
|
||||||
|
pub path: String,
|
||||||
|
/// Snapshot version at the time the view was opened.
|
||||||
|
pub version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-side, file-backed double buffer for the document view.
|
||||||
|
pub struct DocumentSnapshotStore {
|
||||||
|
path: PathBuf,
|
||||||
|
mmap: MmapMut,
|
||||||
|
segment_size: usize,
|
||||||
|
current_version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocumentSnapshotStore {
|
||||||
|
/// Create a new store for `tab`. `segment_size` is the maximum size of one
|
||||||
|
/// snapshot buffer; the file is sized to hold two segments plus the control
|
||||||
|
/// page.
|
||||||
|
pub fn new(tab: usize, segment_size: usize) -> io::Result<Self> {
|
||||||
|
let segment_size = segment_size.next_multiple_of(4096);
|
||||||
|
static STORE_ID: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
let id = STORE_ID.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let path = Self::temp_path(tab, id);
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(&path)?;
|
||||||
|
let total = CONTROL_SIZE + 2 * segment_size;
|
||||||
|
file.set_len(total as u64)?;
|
||||||
|
|
||||||
|
let mut mmap = unsafe { MmapMut::map_mut(&file)? };
|
||||||
|
let control = ControlPage::from_bytes_mut(&mut mmap);
|
||||||
|
control.magic.store(CONTROL_MAGIC, Ordering::Relaxed);
|
||||||
|
control.version.store(0, Ordering::Relaxed);
|
||||||
|
control.active_segment.store(0, Ordering::Relaxed);
|
||||||
|
control.active_len.store(0, Ordering::Relaxed);
|
||||||
|
mmap.flush()?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
path,
|
||||||
|
mmap,
|
||||||
|
segment_size,
|
||||||
|
current_version: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_path(tab: usize, id: usize) -> PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
path.push(format!(
|
||||||
|
"ocs_plugin_doc_{}_{}_{}_{}.bin",
|
||||||
|
std::process::id(),
|
||||||
|
tab,
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis(),
|
||||||
|
id,
|
||||||
|
));
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path the plugin should open to access the mapping.
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize `doc` into the inactive segment and atomically publish it.
|
||||||
|
pub fn publish(&mut self, doc: &CadDocument) -> io::Result<()> {
|
||||||
|
let data = DocumentViewData::from(doc);
|
||||||
|
let bytes = to_bytes::<_, 256>(&data).map_err(|e| {
|
||||||
|
io::Error::new(io::ErrorKind::InvalidData, format!("rkyv serialize: {e}"))
|
||||||
|
})?;
|
||||||
|
if bytes.len() > self.segment_size {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::OutOfMemory,
|
||||||
|
format!(
|
||||||
|
"document view {} bytes exceeds segment size {}",
|
||||||
|
bytes.len(),
|
||||||
|
self.segment_size
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let inactive = {
|
||||||
|
let control = ControlPage::from_bytes_mut(&mut self.mmap);
|
||||||
|
let active = control.active_segment.load(Ordering::Acquire) as usize;
|
||||||
|
1 - active
|
||||||
|
};
|
||||||
|
let offset = CONTROL_SIZE + inactive * self.segment_size;
|
||||||
|
|
||||||
|
self.mmap[offset..offset + bytes.len()].copy_from_slice(&bytes);
|
||||||
|
let control = ControlPage::from_bytes_mut(&mut self.mmap);
|
||||||
|
// Ensure the plugin sees the new length before it sees the new version.
|
||||||
|
control
|
||||||
|
.active_len
|
||||||
|
.store(bytes.len() as u64, Ordering::Release);
|
||||||
|
control
|
||||||
|
.active_segment
|
||||||
|
.store(inactive as u8, Ordering::Release);
|
||||||
|
self.current_version = self.current_version.wrapping_add(1);
|
||||||
|
control
|
||||||
|
.version
|
||||||
|
.store(self.current_version, Ordering::Release);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current published version.
|
||||||
|
pub fn version(&self) -> u64 {
|
||||||
|
ControlPage::from_bytes(&self.mmap)
|
||||||
|
.version
|
||||||
|
.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for DocumentSnapshotStore {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin-side read-only mapping of the host's document view.
|
||||||
|
pub struct SharedDocumentReader {
|
||||||
|
mmap: Mmap,
|
||||||
|
segment_size: usize,
|
||||||
|
cached_version: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedDocumentReader {
|
||||||
|
/// Open the file at `path` read-only and map it. The mapping may initially
|
||||||
|
/// contain no valid snapshot; the caller should `refresh()` before use.
|
||||||
|
pub fn open(path: &Path) -> io::Result<Self> {
|
||||||
|
let file = OpenOptions::new().read(true).open(path)?;
|
||||||
|
let mmap = unsafe { Mmap::map(&file)? };
|
||||||
|
let file_len = mmap.len();
|
||||||
|
let segment_size = if file_len > CONTROL_SIZE {
|
||||||
|
(file_len - CONTROL_SIZE) / 2
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
mmap,
|
||||||
|
segment_size,
|
||||||
|
cached_version: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the host has published a newer snapshot.
|
||||||
|
pub fn has_new_version(&self) -> bool {
|
||||||
|
let control = ControlPage::from_bytes(&self.mmap);
|
||||||
|
if control.magic.load(Ordering::Acquire) != CONTROL_MAGIC {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
control.version.load(Ordering::Acquire) != self.cached_version
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the cached version after the caller has re-bound to a new snapshot.
|
||||||
|
pub fn refresh(&mut self) {
|
||||||
|
let control = ControlPage::from_bytes(&self.mmap);
|
||||||
|
self.cached_version = control.version.load(Ordering::Acquire);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_segment_bytes(&self) -> &[u8] {
|
||||||
|
let control = ControlPage::from_bytes(&self.mmap);
|
||||||
|
let active = control.active_segment.load(Ordering::Acquire) as usize;
|
||||||
|
let len = control.active_len.load(Ordering::Acquire) as usize;
|
||||||
|
let offset = CONTROL_SIZE + active * self.segment_size;
|
||||||
|
if offset + len > self.mmap.len() {
|
||||||
|
return &[];
|
||||||
|
}
|
||||||
|
&self.mmap[offset..offset + len]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn archived(&self) -> Option<&ArchivedDocumentViewData> {
|
||||||
|
let bytes = self.active_segment_bytes();
|
||||||
|
check_archived_root::<DocumentViewData>(bytes).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DocumentReader for SharedDocumentReader {
|
||||||
|
fn entity_count(&self) -> usize {
|
||||||
|
self.archived().map(|doc| doc.entities.len()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn for_each_entity(&self, f: &mut dyn FnMut(ReaderEntity<'_>)) {
|
||||||
|
let Some(doc) = self.archived() else { return };
|
||||||
|
for entity in doc.entities.iter() {
|
||||||
|
let handle = Handle::new(entity.handle);
|
||||||
|
let kind = ReaderEntityKind::from_u8(entity.kind);
|
||||||
|
let layer_name: &str = entity.layer_name.as_str();
|
||||||
|
let point = entity.point.as_ref().map(|p| ReaderPoint {
|
||||||
|
x: p.x,
|
||||||
|
y: p.y,
|
||||||
|
z: p.z,
|
||||||
|
});
|
||||||
|
f(ReaderEntity {
|
||||||
|
handle,
|
||||||
|
kind,
|
||||||
|
layer_name,
|
||||||
|
point,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layer_name(&self, handle: Handle) -> Option<&str> {
|
||||||
|
let doc = self.archived()?;
|
||||||
|
let handle_val = handle.value();
|
||||||
|
doc.layers
|
||||||
|
.iter()
|
||||||
|
.find(|layer| layer.handle == handle_val)
|
||||||
|
.map(|layer| layer.name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_id_name(&self, handle: Handle) -> Option<&str> {
|
||||||
|
let doc = self.archived()?;
|
||||||
|
let handle_val = handle.value();
|
||||||
|
doc.app_ids
|
||||||
|
.iter()
|
||||||
|
.find(|app| app.handle == handle_val)
|
||||||
|
.map(|app| app.name.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw control page shared between host and plugin.
|
||||||
|
#[repr(C, align(8))]
|
||||||
|
struct ControlPage {
|
||||||
|
magic: AtomicU32,
|
||||||
|
_pad0: [u8; 4],
|
||||||
|
version: AtomicU64,
|
||||||
|
active_len: AtomicU64,
|
||||||
|
active_segment: AtomicU8,
|
||||||
|
_pad1: [u8; 7],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlPage {
|
||||||
|
fn from_bytes(mmap: &[u8]) -> &Self {
|
||||||
|
assert!(mmap.len() >= std::mem::size_of::<Self>());
|
||||||
|
assert_eq!(mmap.as_ptr() as usize % std::mem::align_of::<Self>(), 0);
|
||||||
|
unsafe { &*(mmap.as_ptr() as *const Self) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_bytes_mut(mmap: &mut [u8]) -> &mut Self {
|
||||||
|
assert!(mmap.len() >= std::mem::size_of::<Self>());
|
||||||
|
assert_eq!(mmap.as_ptr() as usize % std::mem::align_of::<Self>(), 0);
|
||||||
|
unsafe { &mut *(mmap.as_ptr() as *mut Self) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializable document view. This is the only data type placed in shared
|
||||||
|
/// memory, so it must contain no pointers into host memory.
|
||||||
|
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
#[archive(check_bytes)]
|
||||||
|
pub struct DocumentViewData {
|
||||||
|
pub layers: Vec<LayerView>,
|
||||||
|
pub app_ids: Vec<AppIdView>,
|
||||||
|
pub entities: Vec<EntityView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&CadDocument> for DocumentViewData {
|
||||||
|
fn from(doc: &CadDocument) -> Self {
|
||||||
|
Self {
|
||||||
|
layers: doc.layers.iter().map(LayerView::from).collect(),
|
||||||
|
app_ids: doc.app_ids.iter().map(AppIdView::from).collect(),
|
||||||
|
entities: doc.entities().map(EntityView::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
#[archive(check_bytes)]
|
||||||
|
pub struct LayerView {
|
||||||
|
pub handle: u64,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&acadrust::tables::Layer> for LayerView {
|
||||||
|
fn from(layer: &acadrust::tables::Layer) -> Self {
|
||||||
|
Self {
|
||||||
|
handle: layer.handle.value(),
|
||||||
|
name: layer.name.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
#[archive(check_bytes)]
|
||||||
|
pub struct AppIdView {
|
||||||
|
pub handle: u64,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&acadrust::tables::AppId> for AppIdView {
|
||||||
|
fn from(app_id: &acadrust::tables::AppId) -> Self {
|
||||||
|
Self {
|
||||||
|
handle: app_id.handle.value(),
|
||||||
|
name: app_id.name.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||||
|
#[archive(check_bytes)]
|
||||||
|
pub struct EntityView {
|
||||||
|
pub handle: u64,
|
||||||
|
pub kind: u8,
|
||||||
|
pub layer_name: String,
|
||||||
|
pub point: Option<PointView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&EntityType> for EntityView {
|
||||||
|
fn from(entity: &EntityType) -> Self {
|
||||||
|
let handle = entity.common().handle.value();
|
||||||
|
let kind = ReaderEntityKind::from_entity(entity).to_u8();
|
||||||
|
let layer_name = entity.common().layer.clone();
|
||||||
|
let point = match entity {
|
||||||
|
EntityType::Point(p) => Some(PointView {
|
||||||
|
x: p.location.x,
|
||||||
|
y: p.location.y,
|
||||||
|
z: p.location.z,
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
handle,
|
||||||
|
kind,
|
||||||
|
layer_name,
|
||||||
|
point,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Archive, Serialize, Deserialize, Debug, Clone, Copy)]
|
||||||
|
#[archive(check_bytes)]
|
||||||
|
pub struct PointView {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub z: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReaderEntityKind {
|
||||||
|
/// Convert the simplified kind to a stable `u8` for the shared format.
|
||||||
|
pub fn to_u8(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
ReaderEntityKind::Point => 1,
|
||||||
|
ReaderEntityKind::Line => 2,
|
||||||
|
ReaderEntityKind::Circle => 3,
|
||||||
|
ReaderEntityKind::Arc => 4,
|
||||||
|
ReaderEntityKind::Polyline => 5,
|
||||||
|
ReaderEntityKind::Text => 6,
|
||||||
|
ReaderEntityKind::Other => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode a stable `u8` back to the simplified kind.
|
||||||
|
pub fn from_u8(value: u8) -> Self {
|
||||||
|
match value {
|
||||||
|
1 => ReaderEntityKind::Point,
|
||||||
|
2 => ReaderEntityKind::Line,
|
||||||
|
3 => ReaderEntityKind::Circle,
|
||||||
|
4 => ReaderEntityKind::Arc,
|
||||||
|
5 => ReaderEntityKind::Polyline,
|
||||||
|
6 => ReaderEntityKind::Text,
|
||||||
|
_ => ReaderEntityKind::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::host::{DocumentReader, ReaderEntityKind};
|
||||||
|
use acadrust::entities::Point;
|
||||||
|
use acadrust::tables::Layer;
|
||||||
|
use acadrust::{CadDocument, EntityType};
|
||||||
|
|
||||||
|
fn sample_doc() -> CadDocument {
|
||||||
|
let mut doc = CadDocument::new();
|
||||||
|
doc.layers.add(Layer::new("SURVEY")).unwrap();
|
||||||
|
let mut point = Point::from_coords(10.0, 20.0, 5.0);
|
||||||
|
point.common.layer = "SURVEY".to_string();
|
||||||
|
doc.add_entity(EntityType::Point(point)).unwrap();
|
||||||
|
doc
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_document_reader_roundtrip() {
|
||||||
|
let doc = sample_doc();
|
||||||
|
let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap();
|
||||||
|
store.publish(&doc).unwrap();
|
||||||
|
|
||||||
|
let reader = SharedDocumentReader::open(store.path()).unwrap();
|
||||||
|
assert_eq!(reader.entity_count(), 1);
|
||||||
|
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
reader.for_each_entity(&mut |e| {
|
||||||
|
seen.push((e.kind, e.layer_name.to_string(), e.point, e.handle));
|
||||||
|
});
|
||||||
|
assert_eq!(seen.len(), 1);
|
||||||
|
assert_eq!(seen[0].0, ReaderEntityKind::Point);
|
||||||
|
assert_eq!(seen[0].1, "SURVEY");
|
||||||
|
assert_eq!(
|
||||||
|
seen[0].2,
|
||||||
|
Some(ReaderPoint {
|
||||||
|
x: 10.0,
|
||||||
|
y: 20.0,
|
||||||
|
z: 5.0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
seen[0].3.is_valid(),
|
||||||
|
"reader entity should expose a valid handle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_document_reader_updates_after_publish() {
|
||||||
|
let doc = sample_doc();
|
||||||
|
let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap();
|
||||||
|
store.publish(&doc).unwrap();
|
||||||
|
|
||||||
|
let reader = SharedDocumentReader::open(store.path()).unwrap();
|
||||||
|
assert_eq!(reader.entity_count(), 1);
|
||||||
|
|
||||||
|
let mut doc2 = doc;
|
||||||
|
let mut point2 = Point::from_coords(1.0, 2.0, 3.0);
|
||||||
|
point2.common.layer = "SURVEY".to_string();
|
||||||
|
doc2.add_entity(EntityType::Point(point2)).unwrap();
|
||||||
|
store.publish(&doc2).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(reader.entity_count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn layer_name_lookup_by_handle() {
|
||||||
|
let doc = sample_doc();
|
||||||
|
let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap();
|
||||||
|
store.publish(&doc).unwrap();
|
||||||
|
|
||||||
|
let survey = doc.layers.iter().find(|l| l.name == "SURVEY").unwrap();
|
||||||
|
let reader = SharedDocumentReader::open(store.path()).unwrap();
|
||||||
|
assert_eq!(reader.layer_name(survey.handle), Some("SURVEY"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue