Files
OneCAD-Draft/src/app/plugin_host.rs
T
Hakan Seven 3385160493 Merge PR #1028: add document API with atomic edits
Add the typed document facade and plugin IPC adapter. Prepare bulk edits
before mutation, preserve undo and dirty state, keep profile elevations,
validate numeric inputs and session bindings, and generate Python constructors
with typed results.

Reuse existing entity transforms and profile conversion. Compute mass
properties and closed-surface bounds in cadkernel; pin its main revision
6a5e6fc2822f6af6b2611cd605b44437b35158f0. Keep plugin API metadata at version 6.

Validation: 646 native application tests, 597 kernel tests, 12 document API
tests, 91 plugin API tests, 3 Python tests, default/IPC feature builds and
wasm32 check passed.
2026-09-06 21:43:02 +03:00

904 lines
34 KiB
Rust

// HostSession — plugin-facing API implemented inside `app` (private field access).
use std::any::Any;
use acadrust::tables::AppId;
use acadrust::xdata::ExtendedDataRecord;
use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi};
use ocs_plugin_api::shm::{DocumentSnapshotStore, DocumentViewData};
#[cfg(not(target_arch = "wasm32"))]
use crate::plugin::v4_support;
use super::OpenCADStudio;
/// Session adapter: one active document tab, command line, undo.
pub(crate) struct HostSession<'a> {
app: &'a mut OpenCADStudio,
tab: usize,
doc_store: Option<DocumentSnapshotStore<DocumentViewData>>,
/// In-flight DocApi v2 undo delta (between `push_undo` and `finalize_op`).
doc_api_pending: Option<super::history::PendingDelta>,
}
impl<'a> HostSession<'a> {
pub(crate) fn new(app: &'a mut OpenCADStudio, tab: usize) -> Self {
Self {
app,
tab,
doc_store: None,
doc_api_pending: None,
}
}
pub fn tab_index(&self) -> usize {
self.tab
}
pub fn tab_id(&self) -> u64 {
self.app.tabs[self.tab].id
}
pub fn document_path(&self, tab_id: u64) -> Option<std::path::PathBuf> {
self.app
.tabs
.iter()
.find(|t| t.id == tab_id)
.and_then(|t| t.current_path.clone())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn document_view_v4(&mut self, tab_id: u64) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
if tab_id != self.tab_id() {
return None;
}
v4_support::open_document_view_v4(tab_id, self.document())
}
#[cfg(not(target_arch = "wasm32"))]
pub fn close_document_view_v4(&mut self, tab_id: u64) {
if tab_id == self.tab_id() {
v4_support::close_document_view_v4(tab_id);
}
}
pub fn document(&self) -> &CadDocument {
&self.app.tabs[self.tab].scene.document
}
pub fn document_mut(&mut self) -> &mut CadDocument {
&mut self.app.tabs[self.tab].scene.document
}
pub fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
if self.doc_store.is_none() {
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(self.tab as u64, 8 * 1024 * 1024).ok()?;
store.publish(&self.document().into()).ok()?;
self.doc_store = Some(store);
}
let store = self.doc_store.as_ref()?;
Some(ocs_plugin_api::shm::DocumentViewInfo {
path: store.path().to_string_lossy().to_string(),
version: store.version(),
})
}
fn publish_document_view(&mut self) {
let doc = &self.app.tabs[self.tab].scene.document;
// Only publish views that have been opened by a consumer. V3 is lazily
// created by document_view(); V4 is tracked per-tab by the V4 manager.
if let Some(store) = self.doc_store.as_mut() {
if let Err(e) = store.publish(&doc.into()) {
eprintln!(
"[host] failed to publish document view for tab {}: {e}",
self.tab
);
}
}
#[cfg(not(target_arch = "wasm32"))]
v4_support::publish_document_view_v4(self.tab_id(), doc);
}
pub fn add_entity(&mut self, entity: EntityType) -> Handle {
let handle = self.app.tabs[self.tab].scene.add_entity(entity);
self.publish_document_view();
handle
}
pub fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
let handles = self.app.tabs[self.tab].scene.add_entities(entities);
self.publish_document_view();
handles
}
pub fn bump_geometry(&mut self) {
self.app.tabs[self.tab].scene.bump_geometry();
}
/// Replace the entity carrying `entity`'s handle in place, refreshing the
/// scene's derived caches. Returns `false` when no entity has that handle.
pub fn update_entity(&mut self, entity: EntityType) -> bool {
let ok = self.app.tabs[self.tab].scene.update_entity(entity);
if ok {
self.publish_document_view();
}
ok
}
/// Delete the entity with `handle`, keeping the scene's render caches in
/// sync. Returns `false` when the entity is absent or on a locked layer
/// (which `erase_entities` refuses to remove).
pub fn remove_entity(&mut self, handle: Handle) -> bool {
if self.document().get_entity(handle).is_none() {
return false;
}
self.app.tabs[self.tab].scene.erase_entities(&[handle]);
let removed = self.document().get_entity(handle).is_none();
if removed {
self.publish_document_view();
}
removed
}
// ── XDATA convenience ──────────────────────────────────────────────────
// Plugins persist domain data as XDATA on plain entities so it round-trips
// through DWG/DXF. These wrap the `acadrust::xdata` API keyed by entity
// handle and keep the APPID table in sync.
/// Read the XDATA record for `app_name` attached to entity `handle`, if any.
pub fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord> {
self.document()
.get_entity(handle)?
.common()
.extended_data
.get_record(app_name)
}
/// Attach `record` to entity `handle`, replacing any existing record for the
/// same application. Registers the application in the APPID table when
/// missing so the file stays valid for other CAD apps. Returns `false` when
/// the entity does not exist.
pub fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool {
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
return false;
}
let app = record.application_name.clone();
self.ensure_app_id(&app);
let app_handle = self.document().app_ids.get(&app).map(|a| a.handle.value());
let Some(entity) = self.document_mut().get_entity_mut(handle) else {
return false;
};
let xd = &mut entity.common_mut().extended_data;
// Drop any existing record for this app, then append the new one.
let kept: Vec<_> = xd
.records()
.iter()
.filter(|r| r.application_name != app)
.cloned()
.collect();
xd.clear();
for r in kept {
xd.add_record(r);
}
xd.add_record(record);
// Drop stale verbatim EED for this app so the fresh record — not the
// pre-edit bytes captured on a prior read — wins on the next save.
// Otherwise a plugin's edit made after a save/reopen (which registered
// the app in `raw_dwg_eed`) would not persist.
if let Some(ah) = app_handle {
xd.raw_dwg_eed.retain(|(a, _)| *a != ah);
}
self.publish_document_view();
true
}
/// Remove the XDATA record for `app_name` from entity `handle`. Returns
/// `true` when a record was actually removed.
pub fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool {
if self.app.tabs[self.tab].scene.is_layer_locked(handle) {
return false;
}
let app_handle = self.document().app_ids.get(app_name).map(|a| a.handle.value());
let Some(entity) = self.document_mut().get_entity_mut(handle) else {
return false;
};
let xd = &mut entity.common_mut().extended_data;
let kept: Vec<_> = xd
.records()
.iter()
.filter(|r| r.application_name != app_name)
.cloned()
.collect();
let removed_record = kept.len() != xd.records().len();
// Also drop verbatim EED for this app so the removal persists across a
// save (a record read back from DWG lives in `raw_dwg_eed`).
let removed_raw = app_handle
.map(|ah| xd.raw_dwg_eed.iter().any(|(a, _)| *a == ah))
.unwrap_or(false);
if !removed_record && !removed_raw {
return false;
}
xd.clear();
for r in kept {
xd.add_record(r);
}
if let Some(ah) = app_handle {
xd.raw_dwg_eed.retain(|(a, _)| *a != ah);
}
self.publish_document_view();
true
}
/// Register `name` in the APPID table if it is not already present, so XDATA
/// written under it survives a DWG/DXF round-trip. The entry is given a real
/// handle — a null-handle APPID is written as handle 0, which the DWG EED
/// reference then can't resolve, so the XDATA would be dropped on reopen.
fn ensure_app_id(&mut self, name: &str) {
let doc = self.document_mut();
if !doc.app_ids.contains(name) {
let mut app = AppId::new(name);
app.handle = doc.allocate_handle();
let _ = doc.app_ids.add(app);
}
}
pub fn push_undo(&mut self, label: &str) {
self.app.push_undo_snapshot(self.tab, label);
}
// ── DocApi v2 accessors (used by `app::doc_api` backend) ───────────────
pub(crate) fn scene(&self) -> &crate::scene::Scene {
&self.app.tabs[self.tab].scene
}
pub(crate) fn scene_mut(&mut self) -> &mut crate::scene::Scene {
&mut self.app.tabs[self.tab].scene
}
/// DocApi `push_undo`: capture entity and document-structure changes for one op.
pub(crate) fn begin_doc_api_undo(&mut self, label: &str) {
self.doc_api_pending = self.app.begin_undo(self.tab, label, 1, false);
}
pub(crate) fn cancel_doc_api_undo(&mut self) {
if self.doc_api_pending.take().is_some() {
self.scene_mut().take_undo_recording();
}
}
/// DocApi `finalize_op`: close the delta + republish the document view.
/// The underlying entity op (add/update/remove/register_solid_model) already
/// bumped `geometry_epoch`, so we do NOT bump again here — exactly one undo
/// step is recorded, and the epoch has advanced for this op.
pub(crate) fn commit_doc_api_undo(&mut self) {
self.set_dirty();
if let Some(pending) = self.doc_api_pending.take() {
self.app.commit_undo_delta(self.tab, pending);
}
self.publish_document_view();
}
pub fn set_dirty(&mut self) {
self.app.tabs[self.tab].dirty = true;
}
pub fn push_info(&mut self, msg: &str) {
self.app.command_line.push_info(msg);
}
pub fn push_output(&mut self, msg: &str) {
self.app.command_line.push_output(msg);
}
pub fn push_error(&mut self, msg: &str) {
self.app.command_line.push_error(msg);
}
}
/// The stable contract a plugin's `dispatch` sees. Each method forwards to the
/// inherent `HostSession` method of the same name (inherent methods take
/// resolution priority, so this is plain delegation, not recursion). The
/// per-tab plugin-state accessors expose the raw `Any` box; the typed
/// `ocs_plugin_api::host::plugin_state*` helpers wrap them.
impl HostApi for HostSession<'_> {
fn tab_index(&self) -> usize {
self.tab_index()
}
fn document(&self) -> &CadDocument {
self.document()
}
fn document_mut(&mut self) -> &mut CadDocument {
self.document_mut()
}
fn add_entity(&mut self, entity: EntityType) -> Handle {
self.add_entity(entity)
}
fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
self.add_entities(entities)
}
fn update_entity(&mut self, entity: EntityType) -> bool {
self.update_entity(entity)
}
fn remove_entity(&mut self, handle: Handle) -> bool {
self.remove_entity(handle)
}
fn bump_geometry(&mut self) {
self.bump_geometry()
}
fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord> {
self.read_record(handle, app_name)
}
fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool {
self.write_record(handle, record)
}
fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool {
self.remove_record(handle, app_name)
}
fn push_undo(&mut self, label: &str) {
self.push_undo(label)
}
fn set_dirty(&mut self) {
self.set_dirty()
}
fn push_info(&mut self, msg: &str) {
self.push_info(msg)
}
fn push_output(&mut self, msg: &str) {
self.push_output(msg)
}
fn push_error(&mut self, msg: &str) {
self.push_error(msg)
}
fn start_interactive(&mut self, command: Box<dyn ocs_plugin_api::host::InteractiveCommand>) {
self.app.tabs[self.tab].active_cmd =
Some(Box::new(PluginInteractiveAdapter { inner: command }));
}
fn plugin_state_any(&self, plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> {
self.app.tabs[self.tab]
.plugin_state
.get(plugin_id)
.map(|b| b.as_ref())
}
fn plugin_state_any_mut(&mut self, plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> {
self.app.tabs[self.tab]
.plugin_state
.get_mut(plugin_id)
.map(|b| b.as_mut())
}
fn ensure_plugin_state_any(
&mut self,
plugin_id: &'static str,
init: &mut dyn FnMut() -> Box<dyn Any + Send + Sync>,
) -> &mut (dyn Any + Send + Sync) {
self.app.tabs[self.tab]
.plugin_state
.entry(plugin_id)
.or_insert_with(|| init())
.as_mut()
}
fn document_reader(&self) -> Box<dyn ocs_plugin_api::host::DocumentReader + '_> {
Box::new(ocs_plugin_api::host::CadDocumentReader(self.document()))
}
fn document_view(&mut self) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
self.document_view()
}
fn tab_id(&self) -> u64 {
self.tab_id()
}
fn document_path(&self, tab_id: u64) -> Option<std::path::PathBuf> {
self.document_path(tab_id)
}
#[cfg(not(target_arch = "wasm32"))]
fn document_view_v4(&mut self, tab_id: u64) -> Option<ocs_plugin_api::shm::DocumentViewInfo> {
self.document_view_v4(tab_id)
}
#[cfg(not(target_arch = "wasm32"))]
fn close_document_view_v4(&mut self, tab_id: u64) {
self.close_document_view_v4(tab_id)
}
#[cfg(not(target_arch = "wasm32"))]
fn doc_api_dispatch(&mut self, tab_id: u64, bytes: &[u8]) -> Result<Vec<u8>, String> {
super::doc_api::execute_doc_api(self, tab_id, bytes)
}
}
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
/// to the host's internal `CadCommand`, so a plugin tool drives the host's
/// point-collection flow (viewport clicks or `--serve` coordinates) just like a
/// built-in tool.
struct PluginInteractiveAdapter {
inner: Box<dyn ocs_plugin_api::host::InteractiveCommand>,
}
impl crate::command::CadCommand for PluginInteractiveAdapter {
fn name(&self) -> &'static str {
"PLUGIN"
}
// Every call into the plugin runs under a panic guard (#145): a buggy plugin
// that panics mid-command leaves the host running — the command just ends.
fn prompt(&self) -> String {
crate::plugin::guard("InteractiveCommand::prompt", || self.inner.prompt())
.unwrap_or_default()
}
fn on_point(&mut self, pt: glam::DVec3) -> crate::command::CmdResult {
crate::plugin::guard("InteractiveCommand::on_point", || {
self.inner.on_point([pt.x as f64, pt.y as f64, pt.z as f64])
})
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel)
}
fn on_enter(&mut self) -> crate::command::CmdResult {
crate::plugin::guard("InteractiveCommand::on_enter", || self.inner.on_enter())
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel)
}
fn needs_entity_pick(&self) -> bool {
crate::plugin::guard("InteractiveCommand::needs_object_pick", || {
self.inner.needs_object_pick()
})
.unwrap_or(false)
}
fn on_entity_pick(&mut self, handle: Handle, pt: glam::DVec3) -> crate::command::CmdResult {
crate::plugin::guard("InteractiveCommand::on_object_pick", || {
self.inner
.on_object_pick(handle, [pt.x as f64, pt.y as f64, pt.z as f64])
})
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel)
}
}
/// Bridges an out-of-process plugin's interactive command to the host's
/// `CadCommand`. Events are sent over IPC and the returned `CommandStep` is
/// translated into a `CmdResult`. Prompt and object-pick mode are cached and
/// refreshed after each event.
pub(crate) struct PluginProcessInteractiveAdapter {
pub process: std::sync::Arc<ocs_plugin_api::process::PluginProcess>,
pub command_id: u64,
prompt: Option<String>,
needs_entity_pick: Option<bool>,
}
impl PluginProcessInteractiveAdapter {
pub(crate) fn new(
process: std::sync::Arc<ocs_plugin_api::process::PluginProcess>,
command_id: u64,
) -> Self {
let prompt = process.get_prompt(command_id).ok();
let needs_entity_pick = process.needs_entity_pick(command_id).ok();
Self {
process,
command_id,
prompt,
needs_entity_pick,
}
}
fn refresh(&mut self) {
self.prompt = self.process.get_prompt(self.command_id).ok();
self.needs_entity_pick = self.process.needs_entity_pick(self.command_id).ok();
}
}
impl crate::command::CadCommand for PluginProcessInteractiveAdapter {
fn name(&self) -> &'static str {
"PLUGIN"
}
fn prompt(&self) -> String {
self.prompt.clone().unwrap_or_default()
}
fn on_point(&mut self, pt: glam::DVec3) -> crate::command::CmdResult {
use ocs_plugin_api::ipc::protocol::InteractiveEvent;
let result = self
.process
.interactive_event(
self.command_id,
InteractiveEvent::Point([pt.x, pt.y, pt.z]),
)
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel);
self.refresh();
result
}
fn on_enter(&mut self) -> crate::command::CmdResult {
use ocs_plugin_api::ipc::protocol::InteractiveEvent;
let result = self
.process
.interactive_event(self.command_id, InteractiveEvent::Enter)
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel);
self.refresh();
result
}
fn needs_entity_pick(&self) -> bool {
self.needs_entity_pick.unwrap_or(false)
}
fn on_entity_pick(&mut self, handle: Handle, pt: glam::DVec3) -> crate::command::CmdResult {
use ocs_plugin_api::ipc::protocol::InteractiveEvent;
let result = self
.process
.interactive_event(
self.command_id,
InteractiveEvent::ObjectPick {
handle,
pt: [pt.x, pt.y, pt.z],
},
)
.map(plugin_step_to_result)
.unwrap_or(crate::command::CmdResult::Cancel);
self.refresh();
result
}
}
fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::command::CmdResult {
use crate::command::CmdResult;
use ocs_plugin_api::host::CommandStep;
match step {
CommandStep::NeedPoint => CmdResult::NeedPoint,
CommandStep::Commit(e) => CmdResult::CommitEntity(e),
CommandStep::CommitAndEnd(e) => CmdResult::CommitAndExit(e),
CommandStep::Done | CommandStep::Cancel => CmdResult::Cancel,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::OpenCADStudio;
use acadrust::entities::Point;
use acadrust::xdata::XDataValue;
use ocs_plugin_api::host::DocumentReader;
#[test]
fn xdata_record_round_trips_and_registers_appid() {
let mut app = OpenCADStudio::new_for_test();
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(EntityType::Point(Point::new()));
let mut rec = ExtendedDataRecord::new("DEMO_SURVEY");
rec.add_value(XDataValue::String("PNT-1".to_string()));
rec.add_value(XDataValue::Integer32(42));
assert!(host.write_record(h, rec));
let got = host.read_record(h, "DEMO_SURVEY").expect("record missing");
assert_eq!(got.values.len(), 2);
// APPID registered so the XDATA survives a DWG/DXF round-trip.
assert!(host.document().app_ids.contains("DEMO_SURVEY"));
// A second write replaces rather than duplicates the record.
let mut rec2 = ExtendedDataRecord::new("DEMO_SURVEY");
rec2.add_value(XDataValue::String("PNT-2".to_string()));
assert!(host.write_record(h, rec2));
let got = host.read_record(h, "DEMO_SURVEY").unwrap();
assert_eq!(got.values.len(), 1);
// Removal reports whether anything was dropped.
assert!(host.remove_record(h, "DEMO_SURVEY"));
assert!(host.read_record(h, "DEMO_SURVEY").is_none());
assert!(!host.remove_record(h, "DEMO_SURVEY"));
}
#[test]
fn plugin_state_round_trips_through_hostapi_trait() {
use ocs_plugin_api::host::{self, HostApi};
let mut app = OpenCADStudio::new_for_test();
let mut session = HostSession::new(&mut app, 0);
let host: &mut dyn HostApi = &mut session;
// Absent before first use.
assert!(host::plugin_state::<u32>(&*host, "opencad.demo").is_none());
// Insert via ensure, then mutate.
*host::ensure_plugin_state(host, "opencad.demo", || 7u32) += 1;
assert_eq!(
*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(),
8
);
*host::plugin_state_mut::<u32>(host, "opencad.demo").unwrap() = 100;
assert_eq!(
*host::plugin_state::<u32>(&*host, "opencad.demo").unwrap(),
100
);
}
#[test]
fn add_entities_batch_assigns_handles_and_publishes_once() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let pts: Vec<EntityType> = (0..5)
.map(|i| {
EntityType::Point(Point::at(acadrust::types::Vector3::new(
i as f64, i as f64, 0.0,
)))
})
.collect();
let handles = host.add_entities(pts);
assert_eq!(handles.len(), 5);
assert!(handles.iter().all(|h| !h.is_null()));
// Each handle is unique.
let mut set = std::collections::HashSet::new();
for h in &handles {
assert!(set.insert(h.value()));
}
// All entities are in the document.
for h in &handles {
assert!(host.document().get_entity(*h).is_some());
}
}
#[test]
fn update_entity_replaces_in_place_preserving_handle() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
1.0, 1.0, 0.0,
))));
let epoch_before = host.app.tabs[0].scene.geometry_epoch;
// Edit a snapshot copy (as a plugin would) and commit it.
let mut edited = host.document().get_entity(h).unwrap().clone();
edited.common_mut().layer = "PLUGIN_EDIT".to_string();
assert!(host.update_entity(edited));
// Same handle, edit applied, geometry re-tessellated.
let got = host.document().get_entity(h).expect("entity kept its handle");
assert_eq!(got.common().layer, "PLUGIN_EDIT");
assert_ne!(
host.app.tabs[0].scene.geometry_epoch, epoch_before,
"update should bump geometry"
);
// Updating an unknown handle fails and changes nothing.
let mut ghost = Point::new();
ghost.common.handle = Handle::new(999_999);
assert!(!host.update_entity(EntityType::Point(ghost)));
}
#[test]
fn add_and_update_auto_register_novel_layers_with_real_handles() {
// A plugin adds/edits an entity naming a layer no LAYER command ever
// created. The layer must gain a real table entry (non-null handle) so
// it survives a DWG save instead of collapsing to layer 0 (#252, #67).
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
assert!(!host.document().layers.contains("PLUGIN-LAYER"));
let mut pt = Point::at(acadrust::types::Vector3::new(3.0, 3.0, 0.0));
pt.common.layer = "PLUGIN-LAYER".to_string();
host.add_entity(EntityType::Point(pt));
let layer = host
.document()
.layers
.get("PLUGIN-LAYER")
.expect("novel layer auto-registered on add_entity");
assert!(
!layer.handle.is_null(),
"auto-registered layer must carry a real handle (#67)"
);
// Adding again on the same (now-existing) layer must not duplicate or
// error — the always-present default "0" is likewise never re-created.
let count_before = host.document().layers.len();
host.add_entity(EntityType::Point(Point::new())); // default layer "0"
let mut pt2 = Point::new();
pt2.common.layer = "PLUGIN-LAYER".to_string();
host.add_entity(EntityType::Point(pt2));
assert_eq!(host.document().layers.len(), count_before);
// Retargeting an entity to a novel layer via update_entity registers it.
let h = host.add_entity(EntityType::Point(Point::new()));
let mut edited = host.document().get_entity(h).unwrap().clone();
edited.common_mut().layer = "EDIT-LAYER".to_string();
assert!(host.update_entity(edited));
assert!(host.document().layers.contains("EDIT-LAYER"));
}
#[test]
fn remove_entity_deletes_and_clears_caches() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
2.0, 2.0, 0.0,
))));
assert!(host.document().get_entity(h).is_some());
assert!(host.remove_entity(h));
assert!(host.document().get_entity(h).is_none());
assert!(!host.app.tabs[0].scene.hatches.contains_key(&h));
assert!(!host.app.tabs[0].scene.meshes.contains_key(&h));
// Removing an already-gone handle reports false.
assert!(!host.remove_entity(h));
}
/// A plugin command: second point commits a Point and ends.
struct PlacePoint {
got_first: bool,
}
impl ocs_plugin_api::host::InteractiveCommand for PlacePoint {
fn prompt(&self) -> String {
crate::t!("Pick a point").into_owned()
}
fn on_point(&mut self, pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep {
use ocs_plugin_api::host::CommandStep;
if self.got_first {
let p = acadrust::entities::Point::at(acadrust::types::Vector3::new(
pt[0], pt[1], pt[2],
));
CommandStep::CommitAndEnd(acadrust::EntityType::Point(p))
} else {
self.got_first = true;
CommandStep::NeedPoint
}
}
}
#[test]
fn plugin_interactive_command_drives_host_flow() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
{
let mut host = HostSession::new(&mut app, 0);
host.start_interactive(Box::new(PlacePoint { got_first: false }));
}
assert!(app.tabs[0].active_cmd.is_some());
for pt in [glam::DVec3::new(0.0, 0.0, 0.0), glam::DVec3::new(5.0, 5.0, 0.0)] {
let r = app.tabs[0].active_cmd.as_mut().unwrap().on_point(pt);
let _ = app.apply_cmd_result(r);
}
assert_eq!(app.tabs[0].scene.document.entities().count(), 1);
assert!(app.tabs[0].active_cmd.is_none(), "command should have ended");
}
/// A plugin command that picks an existing object, then marks it.
struct PickThenMark;
impl ocs_plugin_api::host::InteractiveCommand for PickThenMark {
fn prompt(&self) -> String {
crate::t!("Pick an object").into_owned()
}
fn on_point(&mut self, _pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep {
ocs_plugin_api::host::CommandStep::Cancel
}
fn needs_object_pick(&self) -> bool {
true
}
fn on_object_pick(
&mut self,
_handle: acadrust::Handle,
pt: [f64; 3],
) -> ocs_plugin_api::host::CommandStep {
let p =
acadrust::entities::Point::at(acadrust::types::Vector3::new(pt[0], pt[1], pt[2]));
ocs_plugin_api::host::CommandStep::CommitAndEnd(acadrust::EntityType::Point(p))
}
}
#[test]
fn plugin_object_pick_routes_to_command() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let target = {
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at(
acadrust::types::Vector3::new(3.0, 4.0, 0.0),
)));
host.start_interactive(Box::new(PickThenMark));
h
};
// The command requested an entity pick, not a free point.
assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick());
let r = app.tabs[0]
.active_cmd
.as_mut()
.unwrap()
.on_entity_pick(target, glam::DVec3::new(3.0, 4.0, 0.0));
let _ = app.apply_cmd_result(r);
// Original point + the mark the command committed.
assert_eq!(app.tabs[0].scene.document.entities().count(), 2);
}
#[test]
fn host_document_reader_sees_entities() {
use ocs_plugin_api::host::ReaderEntityKind;
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at(
acadrust::types::Vector3::new(7.0, 8.0, 0.0),
)));
let reader = host.document_reader();
assert_eq!(reader.entity_count(), 1);
let mut kinds = Vec::new();
reader.for_each_entity(&mut |e| kinds.push(e.kind));
assert_eq!(kinds, vec![ReaderEntityKind::Point]);
}
#[test]
fn host_document_view_publish_and_read_shared() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let info = host.document_view().unwrap();
let reader =
ocs_plugin_api::shm::SharedDocumentReader::<ocs_plugin_api::shm::DocumentViewData>::open(std::path::Path::new(&info.path))
.unwrap();
assert_eq!(reader.entity_count(), 0);
host.add_entity(acadrust::EntityType::Point(acadrust::entities::Point::at(
acadrust::types::Vector3::new(1.0, 2.0, 0.0),
)));
assert_eq!(reader.entity_count(), 1);
}
/// Read an entity handle from the live document, write XDATA for that
/// handle, read it back, and remove it.
#[test]
fn document_reader_to_xdata_roundtrip() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
7.0, 8.0, 0.0,
))));
{
let reader = host.document_reader();
assert_eq!(reader.entity_count(), 1);
let mut handles = Vec::new();
reader.for_each_entity(&mut |e| handles.push(e.handle));
assert_eq!(handles, vec![h]);
}
let mut rec = ExtendedDataRecord::new("ROUNDTRIP");
rec.add_value(XDataValue::String("from-reader".to_string()));
assert!(host.write_record(h, rec));
let got = host.read_record(h, "ROUNDTRIP").expect("record missing");
assert_eq!(got.values.len(), 1);
assert!(matches!(got.values[0], XDataValue::String(ref s) if s == "from-reader"));
assert!(host.remove_record(h, "ROUNDTRIP"));
assert!(host.read_record(h, "ROUNDTRIP").is_none());
}
/// Publish a shared document view, read the entity handle from shared
/// memory, then write and read-back XDATA through the normal HostApi RPCs.
#[test]
fn shared_document_view_read_then_write_xdata_roundtrip() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let info = host.document_view().unwrap();
let reader =
ocs_plugin_api::shm::SharedDocumentReader::<ocs_plugin_api::shm::DocumentViewData>::open(std::path::Path::new(&info.path))
.unwrap();
let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(
1.0, 2.0, 0.0,
))));
assert_eq!(reader.entity_count(), 1);
let mut handles = Vec::new();
reader.for_each_entity(&mut |e| handles.push(e.handle));
assert_eq!(handles, vec![h]);
let mut rec = ExtendedDataRecord::new("SHM_ROUNDTRIP");
rec.add_value(XDataValue::Integer32(123));
assert!(host.write_record(h, rec));
let got = host
.read_record(h, "SHM_ROUNDTRIP")
.expect("record missing");
assert_eq!(got.values.len(), 1);
assert!(matches!(got.values[0], XDataValue::Integer32(123)));
}
}