//! Runtime host surface (`host` feature). //! //! [`HostApi`] is the `opencadcodec`-typed adapter a plugin uses at *dispatch* time //! — document access, entity creation, XDATA, undo, and the command line. It is //! the stable counterpart to the dependency-free manifest/ribbon contract: a //! plugin's `dispatch` receives `&mut dyn HostApi` rather than the host's //! concrete session type, so an out-of-tree add-on compiles against this crate //! alone. //! //! Per-tab plugin state is keyed by `manifest.id`. The trait exposes it in an //! object-safe `Any` form; use the [`plugin_state`], [`plugin_state_mut`] and //! [`ensure_plugin_state`] helpers for the ergonomic typed access. use std::any::Any; use std::path::PathBuf; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::manifest::PluginManifest; use crate::ribbon::CadModule; // Re-export the opencadcodec crate and the types that appear in the HostApi trait // so out-of-tree plugins can use them without adding their own opencadcodec // dependency (which would risk an ABI-mismatching version). pub use codec; pub use codec::objects::{ DictionaryCloningFlags, KnownXRecordKind, ProxyObjectReference, ProxyReferenceKind, XRecord, XRecordEntry, XRecordSection, XRecordValue, XRecordValueType, }; pub use codec::xdata::{ExtendedDataRecord, XDataValue}; pub use codec::{CadDocument, EntityType, Handle}; use crate::ipc::protocol::{PluginRequest, PluginResponse}; /// Thread-safe handle that can issue host requests from plugin worker threads. /// Out-of-process V4 plugins implement this; in-process hosts may return `None`. pub trait PluginRequestSender: Send + Sync { fn request(&self, req: PluginRequest) -> Result; } /// Error returned when a plugin worker thread cannot issue a host request. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PluginRequestError(pub String); impl std::fmt::Display for PluginRequestError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "PluginRequestError: {}", self.0) } } impl std::error::Error for PluginRequestError {} /// Log level carried by [`PluginNotification::Log`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] pub enum LogLevel { Debug, Info, Warn, Error, } /// A solid primitive the host can build with its geometry kernel. Lengths are /// in drawing units; `center`/`origin` are world coordinates. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SolidPrimitive { /// Axis-aligned box from its center and full extents. Box { center: [f64; 3], size: [f64; 3], }, /// Right triangular prism from its minimum corner and extents. Wedge { origin: [f64; 3], size: [f64; 3], }, /// Cylinder standing on the plane through `center`. Cylinder { center: [f64; 3], radius: f64, height: f64, }, Sphere { center: [f64; 3], radius: f64, }, Torus { center: [f64; 3], major: f64, minor: f64, }, /// Regular pyramid with `sides` base edges. Pyramid { center: [f64; 3], radius: f64, height: f64, sides: u32, }, } /// How two solids combine. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SolidBoolean { Union, /// Remove the second solid from the first. Subtract, Intersect, } /// One step of driving an OCS command from a script (API v7, additive). The /// host runs the real command, so every tool behaves exactly as at the command /// line, and answers with where the command stands. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum CommandRequest { /// Run a whole command line (`OFFSET 5 ...`): the tokens after the name /// answer the prompts in order and a final Enter finishes the command. Run { line: String }, /// Start a command and leave it waiting for its first input. Start { name: String }, /// Answer the current prompt with a world-coordinate point. Point { point: [f64; 3] }, /// Answer with typed text (a distance, an angle, a name). Text { text: String }, /// Answer with a keyword option. Token { text: String }, /// Pick an entity at a point. Entity { handle: Handle, point: [f64; 3] }, /// Complete an object-selection prompt with the current selection. Selection, /// Press Enter. Enter, /// Cancel the running command and close anything it left open. Cancel, } /// Where a command stands after a [`CommandRequest`]. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct CommandOutcome { /// `completed` when nothing is waiting, `waiting_input` when the command /// (or an editor or dialog it opened) needs more. pub status: String, /// What is still open: `command`, `text_editor`, `mtext_editor` or `modal:`. pub blocked_by: Option, /// The waiting command's name and prompt (empty when none). pub command: String, pub prompt: String, /// The kinds of input the prompt accepts (`point`, `entity`, `selection`, /// `text`, `token`, `enter`) and its keyword options. pub accepts: Vec, pub options: Vec, /// Entities in the drawing after the step, and the change. pub entities: u64, pub added: i64, /// Typed tokens no prompt asked for. pub unconsumed: Vec, /// The command line's error message when the step failed. pub error: Option, } /// A change to a drawing table record (API v7, additive). The host validates /// the request, records one undo step and refuses without changing anything /// when it cannot honour it; the result is the record's handle. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum TableOperation { /// Create a layer. `config.name` must be unused and valid. LayerCreate { config: LayerConfig }, /// Change only the properties `config` sets on an existing layer. LayerModify { config: LayerConfig }, /// Rename a layer; entities on it follow. Layer `0` and `Defpoints` stay. LayerRename { from: String, to: String }, /// Delete a layer. A layer that still holds objects is refused unless /// `erase_objects` is set, which erases them with the layer. Layer `0`, /// `Defpoints` and the current layer are never deleted. LayerDelete { name: String, erase_objects: bool }, /// Make an existing layer current. LayerSetCurrent { name: String }, /// Create a text style; unset properties take the AutoCAD defaults. TextStyleCreate { config: TextStyleConfig }, /// Change only the properties `config` sets on an existing text style. TextStyleModify { config: TextStyleConfig }, /// Create a dimension style, optionally copying an existing one first. /// `properties` is a JSON object of DimStyle fields (see the Python docs); /// handles and xref fields are managed by the host and refused. DimStyleCreate { name: String, copy_from: Option, properties: String, }, /// Change only the DimStyle fields the JSON object `properties` names. DimStyleModify { name: String, properties: String }, /// Rename a text or dimension style; references to it follow. `Standard` /// is never renamed and a case-only change is refused. StyleRename { kind: TableStyleKind, from: String, to: String, }, /// Delete a text or dimension style that is neither current nor in use. /// `Standard` is never deleted. StyleDelete { kind: TableStyleKind, name: String }, /// Make a text or dimension style current. StyleSetCurrent { kind: TableStyleKind, name: String }, /// Define a block from existing drawing entities. The entities are copied /// into the definition shifted by `-base_point` (the block's origin); /// `erase_originals` then removes them from the drawing. No insert is /// placed. Returns the block record's handle. BlockCreate { name: String, entities: Vec, base_point: [f64; 3], erase_originals: bool, description: Option, }, /// Change a block's description, explodable or uniform-scale settings. BlockModify { name: String, description: Option, explodable: Option, scale_uniformly: Option, }, /// Rename a block; every insert of it follows. BlockRename { from: String, to: String }, /// Delete an unreferenced block definition and its contents. BlockDelete { name: String }, /// Add a new entity to a block definition, validated exactly as a new /// model-space entity is. The entity's owner is set by the host. Returns the /// new entity's handle. Edit and delete members with the ordinary entity /// operations. BlockEntityAdd { block: String, entity: EntityType }, /// Create a simple linetype. `pattern` is signed lengths in drawing units: /// positive is a dash, negative a gap, zero a dot (2-12 elements with at /// least one dash or dot and one gap). Returns the linetype's handle. LinetypeCreate { name: String, description: String, pattern: Vec, }, /// Change a simple linetype's description or pattern in place. LinetypeModify { name: String, description: Option, pattern: Option>, }, /// Rename a linetype; layers and entities that use it follow. LinetypeRename { from: String, to: String }, /// Delete a linetype that no layer, entity, dimension style or the current /// setting uses. `Continuous`, `ByLayer` and `ByBlock` are never deleted. LinetypeDelete { name: String }, /// Create a paper-space layout with the default page setup and sheet viewport. LayoutCreate { name: String }, /// Rename a paper-space layout (never `Model`). LayoutRename { from: String, to: String }, /// Delete a paper-space layout and everything on it (never `Model`). LayoutDelete { name: String }, /// Switch the active layout (`Model` or a paper-space layout). LayoutSetCurrent { name: String }, /// Change a paper-space layout's sheet: size in millimetres, rotation in /// degrees (0, 90, 180 or 270) and a custom plot scale (numerator, /// denominator). LayoutSetPage { name: String, paper_size: Option<[f64; 2]>, rotation: Option, scale: Option<[f64; 2]>, }, } /// The style tables `TableOperation` can rename, delete or make current. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TableStyleKind { Text, Dim, } /// Properties for creating or changing a text style; `None` leaves a property /// as it was (or the default for a new style). Angles are in radians. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct TextStyleConfig { pub name: String, /// Fixed height; `0` lets each text choose its own. pub height: Option, pub width_factor: Option, pub oblique_angle: Option, pub font_file: Option, pub big_font_file: Option, pub true_type_font: Option, pub backward: Option, pub upside_down: Option, pub vertical: Option, pub annotative: Option, } /// A kernel operation on ACIS-backed solids (API v7, additive). The host owns /// the geometry: a script never sees or rewrites the payload. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum SolidOperation { /// Build a new solid on `layer` (layer `0` when `None`). Create { primitive: SolidPrimitive, layer: Option, }, /// Apply a rigid transform, given as a column-major 4x4 matrix, to an /// existing solid. Scaling and shear are refused. Transform { handle: Handle, matrix: [f64; 16] }, /// Build a planar region from one closed planar profile entity (a circle, /// ellipse, closed polyline or spline, ...). The new region goes on /// `layer`, or on the source's layer when `None`; `delete_source` erases /// the profile afterwards, as the REGION command does. RegionFromProfile { source: Handle, layer: Option, delete_source: bool, }, /// Build a plane surface from one closed planar profile entity. SurfaceFromProfile { source: Handle, layer: Option, delete_source: bool, }, /// Extrude a planar profile along `direction`: a solid when the profile is /// closed, a surface when it is open. Extrude { source: Handle, direction: [f64; 3], layer: Option, delete_source: bool, }, /// Combine two solids into a new one. Both operands are consumed unless /// `keep_operands`; the result goes on `layer` or the first operand's /// layer. The kernel refuses some cases (coincident faces, cuts it has no /// closed form for); a refusal changes nothing. Curved operands can take a /// second or two, and the host thread is busy meanwhile. /// Embed a picture file as an OLE frame whose bottom-left corner is at /// `origin`, `width` drawing units wide, keeping the picture's aspect. /// PNG, JPEG and BMP are stored as read; other formats are re-encoded as /// PNG. The frame goes on `layer` (layer `0` when `None`). EmbedPicture { path: String, origin: [f64; 3], width: f64, layer: Option, }, Boolean { first: Handle, second: Handle, operation: SolidBoolean, layer: Option, keep_operands: bool, }, } /// Value of a host-managed drafting or document setting exposed to plugins. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum HostSettingValue { Text(String), Number(f64), } /// A notification the host sends to a plugin. These are best-effort, /// full-duplex messages correlated with an optional `command_id`. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum HostNotification { InputLine { line: String, }, Cancel, DocumentChanged { version: u64, }, SelectionChanged { handles: Vec, }, Raw(Vec), /// V4 snapshot changed for a specific tab. Discriminant 5. DocumentChangedV4 { tab_id: u64, version: u64, }, /// V4 tab closed notification. Discriminant 6. DocumentTabClosed { tab_id: u64, }, /// V4 selection changed for a specific tab. Discriminant 7. SelectionChangedV4 { tab_id: u64, handles: Vec, }, /// V7 active interactive-command state for a tab. Discriminant 8. CommandStateChanged { tab_id: u64, command: Option, }, /// V7 drawing epoch, broadcast even without an open shared view. Discriminant 9. DrawingChanged { tab_id: u64, epoch: u64, }, /// V9 a document became resident in a tab: a whole document replaced the /// tab's previous content — opened from file, created from a template, or /// loaded by an automation/system open. The counterpart of /// [`HostNotification::DocumentTabClosed`]: with both, a plugin can keep /// per-document state (e.g. runtime designations) in sync from load to /// close. Discriminant 10. DocumentOpened { tab_id: u64, }, /// Fallback for notification variants added in future minor revisions. /// Carries the raw bincode payload so an older peer can ignore it without /// failing deserialization. Unknown(Vec), } impl Serialize for HostNotification { fn serialize(&self, serializer: S) -> Result { let mut bytes = Vec::new(); match self { HostNotification::InputLine { line } => { bytes.push(0); bincode::serialize_into(&mut bytes, line).map_err(serde::ser::Error::custom)?; } HostNotification::Cancel => bytes.push(1), HostNotification::DocumentChanged { version } => { bytes.push(2); bincode::serialize_into(&mut bytes, version).map_err(serde::ser::Error::custom)?; } HostNotification::SelectionChanged { handles } => { bytes.push(3); bincode::serialize_into(&mut bytes, handles).map_err(serde::ser::Error::custom)?; } HostNotification::Raw(data) => { bytes.push(4); bincode::serialize_into(&mut bytes, data).map_err(serde::ser::Error::custom)?; } HostNotification::DocumentChangedV4 { tab_id, version } => { bytes.push(5); bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; bincode::serialize_into(&mut bytes, version).map_err(serde::ser::Error::custom)?; } HostNotification::DocumentTabClosed { tab_id } => { bytes.push(6); bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; } HostNotification::SelectionChangedV4 { tab_id, handles } => { bytes.push(7); bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; bincode::serialize_into(&mut bytes, handles).map_err(serde::ser::Error::custom)?; } HostNotification::CommandStateChanged { tab_id, command } => { bytes.push(8); bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; bincode::serialize_into(&mut bytes, command).map_err(serde::ser::Error::custom)?; } HostNotification::DrawingChanged { tab_id, epoch } => { bytes.push(9); bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; bincode::serialize_into(&mut bytes, epoch).map_err(serde::ser::Error::custom)?; } HostNotification::DocumentOpened { tab_id } => { bytes.push(10); bincode::serialize_into(&mut bytes, tab_id).map_err(serde::ser::Error::custom)?; } HostNotification::Unknown(raw) => bytes.extend_from_slice(raw), } bytes.serialize(serializer) } } impl<'de> Deserialize<'de> for HostNotification { fn deserialize>(deserializer: D) -> Result { let bytes = Vec::::deserialize(deserializer)?; if bytes.is_empty() { return Err(serde::de::Error::custom("empty HostNotification")); } let discriminant = bytes[0]; let rest = &bytes[1..]; match discriminant { 0 => bincode::deserialize(rest) .map(|line| HostNotification::InputLine { line }) .map_err(serde::de::Error::custom), 1 => Ok(HostNotification::Cancel), 2 => bincode::deserialize(rest) .map(|version| HostNotification::DocumentChanged { version }) .map_err(serde::de::Error::custom), 3 => bincode::deserialize(rest) .map(|handles| HostNotification::SelectionChanged { handles }) .map_err(serde::de::Error::custom), 4 => bincode::deserialize(rest) .map(HostNotification::Raw) .map_err(serde::de::Error::custom), 5 => bincode::deserialize(rest) .map(|(tab_id, version)| HostNotification::DocumentChangedV4 { tab_id, version }) .map_err(serde::de::Error::custom), 6 => bincode::deserialize(rest) .map(|tab_id| HostNotification::DocumentTabClosed { tab_id }) .map_err(serde::de::Error::custom), 7 => bincode::deserialize(rest) .map(|(tab_id, handles)| HostNotification::SelectionChangedV4 { tab_id, handles }) .map_err(serde::de::Error::custom), 8 => bincode::deserialize(rest) .map(|(tab_id, command)| HostNotification::CommandStateChanged { tab_id, command }) .map_err(serde::de::Error::custom), 9 => bincode::deserialize(rest) .map(|(tab_id, epoch)| HostNotification::DrawingChanged { tab_id, epoch }) .map_err(serde::de::Error::custom), 10 => bincode::deserialize(rest) .map(|tab_id| HostNotification::DocumentOpened { tab_id }) .map_err(serde::de::Error::custom), _ => Ok(HostNotification::Unknown(bytes)), } } } /// A notification a plugin sends to the host. These are best-effort, /// full-duplex messages correlated with an optional `command_id`. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum PluginNotification { Output { text: String, }, Error { text: String, }, Prompt { text: String, }, Progress { percent: u8, }, Log { level: LogLevel, text: String, }, Raw(Vec), /// V4 REPL status update. Discriminant 6. ReplStatus { status: String, message: String, }, /// Fallback for notification variants added in future minor revisions. /// Carries the raw bincode payload so an older peer can ignore it without /// failing deserialization. Unknown(Vec), } impl Serialize for PluginNotification { fn serialize(&self, serializer: S) -> Result { let mut bytes = Vec::new(); match self { PluginNotification::Output { text } => { bytes.push(0); bincode::serialize_into(&mut bytes, text).map_err(serde::ser::Error::custom)?; } PluginNotification::Error { text } => { bytes.push(1); bincode::serialize_into(&mut bytes, text).map_err(serde::ser::Error::custom)?; } PluginNotification::Prompt { text } => { bytes.push(2); bincode::serialize_into(&mut bytes, text).map_err(serde::ser::Error::custom)?; } PluginNotification::Progress { percent } => { bytes.push(3); bincode::serialize_into(&mut bytes, percent).map_err(serde::ser::Error::custom)?; } PluginNotification::Log { level, text } => { bytes.push(4); bincode::serialize_into(&mut bytes, level).map_err(serde::ser::Error::custom)?; bincode::serialize_into(&mut bytes, text).map_err(serde::ser::Error::custom)?; } PluginNotification::Raw(data) => { bytes.push(5); bincode::serialize_into(&mut bytes, data).map_err(serde::ser::Error::custom)?; } PluginNotification::ReplStatus { status, message } => { bytes.push(6); bincode::serialize_into(&mut bytes, status).map_err(serde::ser::Error::custom)?; bincode::serialize_into(&mut bytes, message).map_err(serde::ser::Error::custom)?; } PluginNotification::Unknown(raw) => bytes.extend_from_slice(raw), } bytes.serialize(serializer) } } impl<'de> Deserialize<'de> for PluginNotification { fn deserialize>(deserializer: D) -> Result { let bytes = Vec::::deserialize(deserializer)?; if bytes.is_empty() { return Err(serde::de::Error::custom("empty PluginNotification")); } let discriminant = bytes[0]; let rest = &bytes[1..]; match discriminant { 0 => bincode::deserialize(rest) .map(|text| PluginNotification::Output { text }) .map_err(serde::de::Error::custom), 1 => bincode::deserialize(rest) .map(|text| PluginNotification::Error { text }) .map_err(serde::de::Error::custom), 2 => bincode::deserialize(rest) .map(|text| PluginNotification::Prompt { text }) .map_err(serde::de::Error::custom), 3 => bincode::deserialize(rest) .map(|percent| PluginNotification::Progress { percent }) .map_err(serde::de::Error::custom), 4 => { let (level, text): (LogLevel, String) = bincode::deserialize(rest).map_err(serde::de::Error::custom)?; Ok(PluginNotification::Log { level, text }) } 5 => bincode::deserialize(rest) .map(PluginNotification::Raw) .map_err(serde::de::Error::custom), 6 => bincode::deserialize(rest) .map(|(status, message)| PluginNotification::ReplStatus { status, message }) .map_err(serde::de::Error::custom), _ => Ok(PluginNotification::Unknown(bytes)), } } } /// An add-on package's entry point: its manifest, optional ribbon tab, and /// command dispatch. Built-in (in-tree) and dynamically-loaded (cdylib) plugins /// implement the same trait from this crate, so an out-of-tree add-on targets /// the stable contract rather than the host binary. pub trait BuiltinPlugin: Send + Sync { fn manifest(&self) -> &'static PluginManifest; fn ribbon(&self) -> Box; fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool; /// Long-running REPL code execution (API v4). The plugin may start the /// work on another thread and call `respond` when finished; the runner /// will forward the result to the host. The supplied `host` is the active /// document tab's API surface, so the REPL session is tied to a tab. /// /// Returning `false` means the plugin does not support code execution. fn start_execute_code( &mut self, _host: &mut dyn HostApi, _command_id: u64, _code: &str, _source: CommandSource, _respond: Box, ) -> bool { false } /// State-only callback for host-to-plugin notifications. Added in API v4; /// the V4 runner guarantees it is only called for plugins that report /// API major 4 or newer. fn on_notification(&mut self, _command_id: Option, _notification: HostNotification) {} /// Lifecycle callback invoked once after the runner connects. The plugin /// receives the active document tab's `HostApi` so it can set up worker /// threads or cache `plugin_request_sender()` before any user command runs. /// /// Added in API v5. The runner only calls this for plugins that report API /// major 5 or newer. fn on_load(&mut self, _host: &mut dyn HostApi) {} } /// A point-driven interactive command a plugin starts via /// [`HostApi::start_interactive`]. The host shows the prompt, collects points — /// clicked in the viewport, or fed as coordinates over the `--serve` automation /// API — and commits the entities the command yields, exactly like a built-in /// tool. This is the plugin-facing slice of the host's command machinery; it /// covers click-to-place placement without exposing the host's internal command /// trait. pub trait InteractiveCommand: Send { /// Prompt for the next point. fn prompt(&self) -> String; /// A point was supplied (clicked or typed `x,y[,z]`). Returns the next step. fn on_point(&mut self, pt: [f64; 3]) -> CommandStep; /// Enter pressed with no point — e.g. to finish a multi-point command. fn on_enter(&mut self) -> CommandStep { CommandStep::Cancel } /// When `true`, the next input picks an existing **entity** (the user clicks /// on it; over `--serve`, a handle is supplied) rather than a free point — /// the host then calls [`on_object_pick`](Self::on_object_pick). Use this to /// reference existing geometry (e.g. connect a pipe between two structures). fn needs_object_pick(&self) -> bool { false } /// An existing entity was picked: its `handle` and the pick point. Read the /// entity's data (XDATA / geometry) via `HostApi`, keyed by the handle. fn on_object_pick(&mut self, _handle: Handle, _pt: [f64; 3]) -> CommandStep { CommandStep::Cancel } /// Real-time preview geometry (lines, arcs, polylines) to render as the cursor moves. /// Each wire can specify custom vertices and an optional color (defaults to host cyan). fn on_cursor_move(&mut self, _pt: [f64; 3]) -> Vec { Vec::new() } } /// A preview wire rendered in real-time during interactive commands. /// Supports straight polylines as well as analytical circles and arcs /// that render via GPU shaders with infinite smoothness. #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))] pub enum PreviewWire { /// A connected sequence of straight line segments in world space. Polyline { points: Vec<[f64; 3]>, color: Option<[f32; 4]>, }, /// An analytical circle rendered with sub-pixel GPU anti-aliasing. Circle { center: [f64; 3], radius: f64, color: Option<[f32; 4]>, }, /// An analytical circular arc swept counter-clockwise from `start_angle_rad` to `end_angle_rad`. Arc { center: [f64; 3], radius: f64, start_angle_rad: f64, end_angle_rad: f64, color: Option<[f32; 4]>, }, } impl PreviewWire { /// Create a polyline preview wire with default host cyan color. pub fn new(points: Vec<[f64; 3]>) -> Self { Self::Polyline { points, color: None, } } /// Optional RGBA color (0.0 to 1.0) specified for this preview wire. pub fn color(&self) -> Option<[f32; 4]> { match self { Self::Polyline { color, .. } => *color, Self::Circle { color, .. } => *color, Self::Arc { color, .. } => *color, } } /// Create a polyline preview wire with a custom RGBA color. pub fn with_color(points: Vec<[f64; 3]>, color: [f32; 4]) -> Self { Self::Polyline { points, color: Some(color), } } /// Create a straight line segment preview between two points. pub fn line(from: [f64; 3], to: [f64; 3], color: Option<[f32; 4]>) -> Self { Self::Polyline { points: vec![from, to], color, } } /// Create an analytical circle preview. pub fn circle(center: [f64; 3], radius: f64, color: Option<[f32; 4]>) -> Self { Self::Circle { center, radius, color, } } /// Create an analytical circular arc preview. pub fn arc( center: [f64; 3], radius: f64, start_angle_rad: f64, end_angle_rad: f64, color: Option<[f32; 4]>, ) -> Self { Self::Arc { center, radius, start_angle_rad, end_angle_rad, color, } } } impl From> for PreviewWire { fn from(points: Vec<[f64; 3]>) -> Self { Self::new(points) } } /// The outcome of an [`InteractiveCommand`] step. #[derive(Debug)] #[cfg_attr(feature = "host", derive(serde::Serialize, serde::Deserialize))] pub enum CommandStep { /// Need another point; keep the command active. NeedPoint, /// Commit an entity to the document and keep collecting points. Commit(EntityType), /// Commit an entity and end the command. CommitAndEnd(EntityType), /// End the command without committing. Done, /// Cancel the command. Cancel, // New variants go after the existing ones: the step crosses the plugin // IPC, which encodes a variant by its position, so inserting one earlier // would make a plugin built against the previous API send `Done` and // have the host read it as something else. /// Commit multiple entities to the document and keep collecting points. CommitMany(Vec), /// Commit multiple entities to the document and end the command. CommitManyAndEnd(Vec), } /// Export a `BuiltinPlugin` from a `cdylib` so the host can load it at runtime. /// /// Emits the two C symbols the loader looks for: `ocs_plugin_api_version` /// (checked before anything else, so an ABI-incompatible build is rejected /// without running its code) and `ocs_plugin_register` (constructs the plugin /// and hands ownership to the host as a boxed trait object). /// /// ```ignore /// ocs_plugin_api::export_plugin!(MyPlugin::new()); /// ``` #[macro_export] macro_rules! export_plugin { ($ctor:expr) => { #[no_mangle] pub extern "C" fn ocs_plugin_api_version() -> u32 { $crate::API_VERSION } #[no_mangle] pub extern "C" fn ocs_plugin_register( ) -> *mut ::std::boxed::Box { // The constructor runs across a C ABI boundary; a panic unwinding // past it is undefined behavior. Contain it and report failure as a // null pointer, which the host loader treats as "registration // failed" rather than crashing the runner process. match ::std::panic::catch_unwind(::std::panic::AssertUnwindSafe(|| { let plugin: ::std::boxed::Box = ::std::boxed::Box::new($ctor); ::std::boxed::Box::into_raw(::std::boxed::Box::new(plugin)) })) { ::std::result::Result::Ok(ptr) => ptr, ::std::result::Result::Err(_) => ::std::ptr::null_mut(), } } }; } /// The plugin-facing runtime surface for one active document tab. pub trait HostApi { /// Index of the tab this session targets. fn tab_index(&self) -> usize; // ── Document ──────────────────────────────────────────────────────────── fn document(&self) -> &CadDocument; /// Mutable access to the active document. /// /// For an **out-of-process** plugin this borrows a *local snapshot*: edits /// to existing entities made through it are NOT sent back to the host and /// are silently discarded. To modify or delete entities from any plugin, /// use [`add_entity`](Self::add_entity), [`update_entity`](Self::update_entity) /// and [`remove_entity`](Self::remove_entity), which are committed to the /// host document over IPC. fn document_mut(&mut self) -> &mut CadDocument; /// Add an entity to the active document, returning its handle. fn add_entity(&mut self, entity: EntityType) -> Handle; /// Replace the existing entity that carries `entity`'s handle, preserving /// its identity (handle and owning block). Returns `false` when no entity /// has that handle. This is the sanctioned way to commit in-place edits /// from an out-of-process plugin — mutating `document_mut()` does not work /// across the process boundary. fn update_entity(&mut self, entity: EntityType) -> bool { let handle = entity.common().handle; match self.document_mut().get_entity_mut(handle) { Some(slot) => { *slot = entity; true } None => false, } } /// Delete the entity with `handle` (and any derived render caches). Returns /// `true` when an entity was removed. fn remove_entity(&mut self, handle: Handle) -> bool { self.document_mut().remove_entity(handle).is_some() } /// Mark the scene geometry dirty so it is re-tessellated next frame. fn bump_geometry(&mut self); // ── XDATA ─────────────────────────────────────────────────────────────── /// Read the XDATA record for `app_name` on entity `handle`, if any. fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord>; /// Attach `record` to entity `handle`, replacing any existing record for the /// same application and registering the APPID. Returns `false` if the entity /// does not exist. fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool; /// Remove the XDATA record for `app_name` from entity `handle`. Returns /// `true` if a record was removed. fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool; // ── Undo / dirty ──────────────────────────────────────────────────────── fn push_undo(&mut self, label: &str); fn set_dirty(&mut self); // ── Command line ──────────────────────────────────────────────────────── fn push_info(&mut self, msg: &str); fn push_output(&mut self, msg: &str); fn push_error(&mut self, msg: &str); /// Start a plugin-defined interactive (click-to-place) command on the active /// tab. The host drives it through its normal point-collection flow. fn start_interactive(&mut self, command: Box); // ── 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_mut(&mut self, plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)>; /// Get the state for `plugin_id`, inserting `init()`'s result if absent. /// Returns `None` when this host cannot store state (e.g. out-of-process). fn ensure_plugin_state_any( &mut self, plugin_id: &'static str, init: &mut dyn FnMut() -> Box, ) -> Option<&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; /// 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 { None } // ── Notifications (added in API v4; appended at the end to keep vtable // indices stable for V2/V3 plugins) ─────────────────────────────────────── /// Send a best-effort notification from the plugin to the host. /// /// In-process plugins can override this to forward to the host event loop; /// out-of-process plugins send a V4 notification frame. fn notify_plugin(&mut self, _command_id: Option, _notification: PluginNotification) {} /// Poll for a host-to-plugin notification, if any. /// /// Returns the optional `command_id` used to correlate the notification /// with a running command, and the notification payload. Long-running /// plugins should call this periodically to drain the bounded queue. fn try_recv_notification(&mut self) -> Option<(Option, HostNotification)> { None } /// Returns a thread-safe handle that can issue host requests from worker /// threads. Out-of-process V4 plugins implement this; in-process hosts may /// return `None`. fn plugin_request_sender(&self) -> Option> { None } // ── V4 tab/document identity (added for REPL; appended at the end) ─────── /// Stable tab identifier for the active document tab. fn tab_id(&self) -> u64 { self.tab_index() as u64 } /// Open (or refresh) the host-side V4 shared document view for `tab_id` /// and return the mapping information. In-process hosts implement this; /// out-of-process plugin proxies return `None`. fn document_view_v4(&mut self, tab_id: u64) -> Option { let _ = tab_id; None } /// Close the host-side V4 shared document view for `tab_id`. fn close_document_view_v4(&mut self, tab_id: u64) { let _ = tab_id; } // ── Batch entities (added after API v4; appended at the very end so older // plugins compiled without it keep stable vtable indices) ──────────────── /// Add multiple entities to the active document, returning their handles. /// The default implementation calls [`add_entity`](Self::add_entity) for each /// entity; hosts should override it for batch efficiency. fn add_entities(&mut self, entities: Vec) -> Vec { entities.into_iter().map(|e| self.add_entity(e)).collect() } /// Return the filesystem path of the document in tab `tab_id`, if any. /// Added in API v5. The default returns `None`; in-process hosts should /// override it to expose the real path. fn document_path(&self, tab_id: u64) -> Option { let _ = tab_id; None } /// Read a named host setting without invoking the command dispatcher. /// Names are case-insensitive. Hosts may support only a subset. fn system_variable(&self, _name: &str) -> Option { None } /// Set a named host setting without re-entering command dispatch. /// Returns the effective value after validation and normalization. fn set_system_variable( &mut self, _name: &str, _value: HostSettingValue, ) -> Result { Err("system variable is not supported by this host".to_owned()) } /// Validate and replace existing entities as one undo step (API v7). /// Callers clone entities from `document()`, change only supported fields, /// and submit all replacements together. Identity and kind must be kept. /// An error leaves the drawing and undo history unchanged. fn update_entities_transaction( &mut self, _label: &str, _entities: Vec, ) -> Result<(), String> { Err("entity transactions are not supported by this host".to_owned()) } /// Current ordered selection in this session's tab (API v7). fn selection(&self) -> Vec { Vec::new() } /// Replace this tab's selection with exactly these handles, in order. /// Missing or duplicate handles reject the request (API v7). fn set_selection(&mut self, _handles: &[Handle]) -> Result<(), String> { Err("selection writes are not supported by this host".to_owned()) } /// Run a kernel-backed solid operation and return the created or updated /// entity's handle. Refused, without any change, when the kernel cannot /// perform it losslessly (API v7, additive). fn solid_operation(&mut self, _operation: SolidOperation) -> Result { Err("solid operations are not supported by this host".to_owned()) } /// Drive an OCS command from a script (API v7, additive). Refused, with /// nothing run, when another command is already active or the request names /// a command that could end the session or re-enter the plugin. fn run_command(&mut self, _request: CommandRequest) -> Result { Err("running commands is not supported by this host".to_owned()) } /// Create, change, rename, delete or select a drawing table record (API v7, /// additive). Returns the record's handle; a refusal changes nothing. fn table_operation(&mut self, _operation: TableOperation) -> Result { Err("table operations are not supported by this host".to_owned()) } /// Add a layer to the active document with full initial properties. /// If an optional property in `config` is `None`, standard CAD defaults are applied. /// Returns `None` if the layer already exists or `config.name` is invalid. /// /// To modify properties of an already existing layer, use [`modify_layer`](Self::modify_layer). fn add_layer(&mut self, config: LayerConfig) -> Option { let _ = config; None } /// Modify specified properties of an existing layer in the active document. /// Properties that are `None` in `config` are left untouched as-is. /// Returns `false` if the layer does not exist or `config.name` is invalid. /// /// To create a new layer, use [`add_layer`](Self::add_layer). fn modify_layer(&mut self, config: LayerConfig) -> bool { let _ = config; false } /// Run a command on the active document tab's command line (AutoLISP / script style). /// /// The string is passed to the host command driver as if entered into the command line, /// supporting command names, space/newline-delimited arguments, AutoCAD-compatible /// aliases, system variable setters (e.g. `SETVAR PDMODE 3`), and display commands /// (e.g. `VSCURRENT`, `GRID`, `SNAP`). /// /// Headless async tasks spawned by commands are automatically pumped through the host /// application lifecycle. /// /// Returns `true` if the command was recognized and initiated successfully, `false` otherwise. fn execute_command(&mut self, cmd: &str) -> bool { let _ = cmd; false } // ── XRecord objects (added in API v8; appended at the end to keep vtable // indices stable for older plugins) ──────────────────────────────────────── /// Create an XRecord named `name` under the dictionary addressed by /// `dictionary_path`, storing `entries` (API v8, additive). /// /// The path is walked from the drawing's named-object dictionary root: an /// empty path addresses the root itself, and missing intermediate /// dictionaries are created, so the first call seeds a plugin's namespace. /// Every refusal (empty or invalid names, a path fragment that names a /// non-dictionary object, a name already in use — comparison is /// case-insensitive like dictionary lookups) happens **before** any change, /// so a failure leaves the drawing untouched. The record is registered in /// its dictionary and owned by it; the returned handle stays stable across /// updates. /// /// Undo composition — the entity-write contract: a mutation performed /// inside a plugin-opened [`HostApi::push_undo`] scope joins that scope, /// so an operation composed of several writes (entities and XRecords) is /// a single undo step under the plugin's own label. With no scope open /// the change is one undo step of its own. Refusals record no undo step. fn create_xrecord( &mut self, _dictionary_path: &[&str], _name: &str, _entries: Vec, ) -> Result { Err("XRecord creation is not supported by this host".to_owned()) } /// Read the entries of an existing XRecord by name (API v8, additive). /// /// Resolves `dictionary_path` under the named-object dictionary root and /// returns the record's entries verbatim. Fails when the path, the name or /// the record is missing, or when the name addresses a different object /// kind. fn read_xrecord( &self, _dictionary_path: &[&str], _name: &str, ) -> Result, String> { Err("XRecord reads are not supported by this host".to_owned()) } /// Atomically replace the entries of an existing XRecord (API v8, /// additive). Returns the record's handle. /// /// Neither the path nor its dictionaries are created: an update must never /// create anything silently. Identity (handle, owning dictionary) stays; /// the entries and their DWG handle-reference stream are replaced and the /// record's verbatim raw payloads are dropped, so subsequent saves emit the /// new content. A refusal leaves the drawing untouched. Undo composition — /// the entity-write contract: a mutation inside a plugin-opened /// [`HostApi::push_undo`] scope joins that scope; with no scope open it is /// one undo step of its own, whose undo restores the previous entries /// byte-exactly. Refusals record no undo step. fn update_xrecord( &mut self, _dictionary_path: &[&str], _name: &str, _entries: Vec, ) -> Result { Err("XRecord updates are not supported by this host".to_owned()) } /// Delete the XRecord named `name` and its dictionary entry (API v8, /// additive). Returns the removed record, so audits keep the prior /// content. A refusal leaves the drawing untouched. Undo composition — the /// entity-write contract: a mutation inside a plugin-opened /// [`HostApi::push_undo`] scope joins that scope; with no scope open it is /// one undo step of its own. Refusals record no undo step. fn delete_xrecord( &mut self, _dictionary_path: &[&str], _name: &str, ) -> Result { Err("XRecord deletes are not supported by this host".to_owned()) } /// List the XRecord names under the dictionary addressed by /// `dictionary_path` (API v8, additive). /// /// Returns only the dictionary keys that address XRecord objects, in the /// dictionary's own order, so consumers can audit and export without /// knowing names in advance. Fails when the path does not address a /// dictionary. fn list_xrecords(&self, _dictionary_path: &[&str]) -> Result, String> { Err("XRecord listing is not supported by this host".to_owned()) } // ── Reference-only designation (added in API v9; appended at the end to // keep vtable indices stable for older plugins) ────────────────────────── /// Mark (or unmark) an entity as reference-only (API v9, additive). /// /// A reference-only entity is plugin-owned display geometry: it renders /// normally, but the built-in edit paths treat it like an entity on a /// locked layer — it is neither grip-editable nor click-pickable (a click /// passes through to the editable entity underneath), window and box /// selections skip it, and user-driven writes refuse it. The owning /// plugin keeps full write access through this API: `update_entity`, /// [`update_entities_transaction`](HostApi::update_entities_transaction), /// `remove_entity` and the XDATA record methods keep working, so derived /// display geometry can be refreshed in place. /// /// The designation is a scene-scoped, in-memory set keyed by entity /// handle: it is not written to the saved DWG/DXF (the host stores no /// state of its own in the file), it lives and dies with the drawing tab, /// and a reloaded drawing starts undesignated — the owning plugin /// re-marks its entities when /// [`on_notification`](BuiltinPlugin::on_notification) delivers /// [`HostNotification::DocumentOpened`], making the in-memory designation /// fully equivalent to a persisted one for its use case. /// /// Fails when the entity does not exist or is on a locked layer (the same /// policy as the XDATA record methods). A call that would not change the /// current designation succeeds without recording an undo step — and /// without publishing a document change, so idempotent re-marks cannot /// loop with the notification dispatch. /// /// Undo composition — the entity-write contract: a change performed /// inside a plugin-opened [`HostApi::push_undo`] scope joins that scope, /// so an operation composed of several writes is a single undo step under /// the plugin's own label. With no scope open the change is one undo step /// of its own. Refusals record no undo step. fn set_reference_only(&mut self, _handle: Handle, _reference_only: bool) -> Result<(), String> { Err("The reference-only designation is not supported by this host".to_owned()) } } /// Configuration properties for creating or modifying a layer. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct LayerConfig { pub name: String, pub color: Option, pub linetype: Option, pub lineweight: Option, pub off: Option, pub frozen: Option, pub locked: Option, pub plottable: Option, pub transparency: Option, pub description: Option, } /// 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, } /// 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`. pub fn plugin_state<'a, T: Any + Send + Sync>( host: &'a dyn HostApi, plugin_id: &str, ) -> Option<&'a T> { host.plugin_state_any(plugin_id)?.downcast_ref::() } /// Typed mutable access to per-tab plugin state stored under `plugin_id`. pub fn plugin_state_mut<'a, T: Any + Send + Sync>( host: &'a mut dyn HostApi, plugin_id: &str, ) -> Option<&'a mut T> { host.plugin_state_any_mut(plugin_id)?.downcast_mut::() } /// Typed get-or-insert of per-tab plugin state stored under `plugin_id`. /// /// Returns `None` when this host cannot hold state (out-of-process plugins) /// or when existing state under `plugin_id` has a different type. pub fn ensure_plugin_state<'a, T: Any + Send + Sync>( host: &'a mut dyn HostApi, plugin_id: &'static str, init: impl FnOnce() -> T, ) -> Option<&'a mut T> { let mut init = Some(init); let any = host.ensure_plugin_state_any(plugin_id, &mut || match init.take() { Some(make) => Box::new(make()), None => Box::new(()), }); any?.downcast_mut::() } #[cfg(feature = "host")] mod repl { use serde::{Deserialize, Serialize}; /// Source surface that submitted a REPL code snippet. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] pub enum CommandSource { CommandLine, Script, Editor, } /// Outcome of a REPL `ExecuteCode` request. #[derive(Debug, Clone, Serialize, Deserialize)] #[non_exhaustive] pub struct ExecutionResult { pub success: bool, pub output: Option, pub error: Option, pub error_type: Option, pub traceback: Option, pub line_number: Option, pub column_number: Option, pub duration_ms: f64, } impl ExecutionResult { /// Create a new execution result. pub fn new( success: bool, output: Option, error: Option, error_type: Option, traceback: Option, position: Option<(u32, u32)>, duration_ms: f64, ) -> Self { let (line_number, column_number) = match position { Some((l, c)) => (Some(l), Some(c)), None => (None, None), }; Self { success, output, error, error_type, traceback, line_number, column_number, duration_ms, } } } /// Spawn a thread that runs `f` and calls `respond` with the returned /// `ExecutionResult`. If `f` panics, `respond` is called with a panic error /// result instead, so the host never waits indefinitely for a callback that /// will never arrive. pub fn execute_code_guard( respond: Box, f: F, ) -> std::thread::JoinHandle<()> where F: FnOnce() -> ExecutionResult + Send + 'static, { std::thread::spawn(move || { let result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { Ok(result) => result, Err(payload) => { let msg = payload .downcast_ref::<&str>() .copied() .or_else(|| payload.downcast_ref::().map(|s| s.as_str())) .unwrap_or("execute_code panicked"); ExecutionResult { success: false, output: None, error: Some(format!("panic: {}", msg)), error_type: Some("panic".to_string()), traceback: None, line_number: None, column_number: None, duration_ms: 0.0, } } }; respond(result); }) } } #[cfg(feature = "host")] pub use repl::{execute_code_guard, CommandSource, ExecutionResult};