forked from 1b-agent/opencadstudio-upstream
Close the no-load-callback gap: on_notification was a dead dispatch sink —
a plugin had no way to learn that the active document changed, the selection
changed, or that a document opened, so runtime designations could not be
re-seeded after a load.
Dispatch the existing plain variants at the fingerprint-guarded boundaries
the v4 bridge already uses: DocumentChanged (the active tab's settled
geometry epoch) and SelectionChanged (the selection set) leave through
BuiltinPlugin::on_notification at the post-message boundary — after the
mutation settles and pending history scopes closed, never mid-undo or
mid-pick — and at the automation post-op boundary. Plugin-initiated writes
publish immediately after settling, like the drawing-changed event; the
boundary fingerprint guard deduplicates.
Add the additive HostNotification::DocumentOpened { tab_id } variant
(discriminant 10, the counterpart of DocumentTabClosed): fired exactly once
per whole-document install — file open, template new, system open — keyed on
a new per-tab document generation, so an edit can never repeat it and a
reload fires it once. The API version stays v9 (the bump is unreleased).
Write semantics for handlers, tested and documented: a handler may call
HostApi write methods through the same session (set_reference_only at
minimum); its write bumps the epoch, the next boundary notifies once more,
and because re-marking already-designated geometry is a documented no-op —
no epoch bump, no undo step, no publish — the loop terminates. History
(undo/redo) replay itself emits nothing; the boundary is the only channel.
No Python mapping, like the rest of this increment.
1492 lines
60 KiB
Rust
1492 lines
60 KiB
Rust
//! 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<PluginResponse, PluginRequestError>;
|
|
}
|
|
|
|
/// 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:<kind>`.
|
|
pub blocked_by: Option<String>,
|
|
/// 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<String>,
|
|
pub options: Vec<String>,
|
|
/// 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<String>,
|
|
/// The command line's error message when the step failed.
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
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<Handle>,
|
|
base_point: [f64; 3],
|
|
erase_originals: bool,
|
|
description: Option<String>,
|
|
},
|
|
/// Change a block's description, explodable or uniform-scale settings.
|
|
BlockModify {
|
|
name: String,
|
|
description: Option<String>,
|
|
explodable: Option<bool>,
|
|
scale_uniformly: Option<bool>,
|
|
},
|
|
/// 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<f64>,
|
|
},
|
|
/// Change a simple linetype's description or pattern in place.
|
|
LinetypeModify {
|
|
name: String,
|
|
description: Option<String>,
|
|
pattern: Option<Vec<f64>>,
|
|
},
|
|
/// 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<u16>,
|
|
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<f64>,
|
|
pub width_factor: Option<f64>,
|
|
pub oblique_angle: Option<f64>,
|
|
pub font_file: Option<String>,
|
|
pub big_font_file: Option<String>,
|
|
pub true_type_font: Option<String>,
|
|
pub backward: Option<bool>,
|
|
pub upside_down: Option<bool>,
|
|
pub vertical: Option<bool>,
|
|
pub annotative: Option<bool>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
},
|
|
/// 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<String>,
|
|
delete_source: bool,
|
|
},
|
|
/// Build a plane surface from one closed planar profile entity.
|
|
SurfaceFromProfile {
|
|
source: Handle,
|
|
layer: Option<String>,
|
|
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<String>,
|
|
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<String>,
|
|
},
|
|
Boolean {
|
|
first: Handle,
|
|
second: Handle,
|
|
operation: SolidBoolean,
|
|
layer: Option<String>,
|
|
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<Handle>,
|
|
},
|
|
Raw(Vec<u8>),
|
|
/// 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<Handle>,
|
|
},
|
|
/// V7 active interactive-command state for a tab. Discriminant 8.
|
|
CommandStateChanged {
|
|
tab_id: u64,
|
|
command: Option<String>,
|
|
},
|
|
/// 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<u8>),
|
|
}
|
|
|
|
impl Serialize for HostNotification {
|
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
let bytes = Vec::<u8>::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<u8>),
|
|
/// 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<u8>),
|
|
}
|
|
|
|
impl Serialize for PluginNotification {
|
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
|
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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
|
let bytes = Vec::<u8>::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<dyn CadModule>;
|
|
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<dyn FnOnce(ExecutionResult) + Send>,
|
|
) -> 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<u64>, _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<PreviewWire> {
|
|
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<Vec<[f64; 3]>> 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<EntityType>),
|
|
/// Commit multiple entities to the document and end the command.
|
|
CommitManyAndEnd(Vec<EntityType>),
|
|
}
|
|
|
|
/// 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<dyn $crate::host::BuiltinPlugin> {
|
|
// 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<dyn $crate::host::BuiltinPlugin> =
|
|
::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<dyn InteractiveCommand>);
|
|
|
|
// ── 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<dyn Any + Send + Sync>,
|
|
) -> 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<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
|
|
}
|
|
|
|
// ── 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<u64>, _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<u64>, 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<Box<dyn PluginRequestSender>> {
|
|
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<crate::shm::DocumentViewInfo> {
|
|
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<EntityType>) -> Vec<Handle> {
|
|
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<PathBuf> {
|
|
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<HostSettingValue> {
|
|
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<HostSettingValue, String> {
|
|
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<EntityType>,
|
|
) -> 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<Handle> {
|
|
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<Handle, String> {
|
|
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<CommandOutcome, String> {
|
|
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<Handle, String> {
|
|
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<Handle> {
|
|
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<XRecordEntry>,
|
|
) -> Result<Handle, String> {
|
|
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<Vec<XRecordEntry>, 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<XRecordEntry>,
|
|
) -> Result<Handle, String> {
|
|
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<XRecord, String> {
|
|
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<Vec<String>, 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<codec::types::Color>,
|
|
pub linetype: Option<String>,
|
|
pub lineweight: Option<codec::types::LineWeight>,
|
|
pub off: Option<bool>,
|
|
pub frozen: Option<bool>,
|
|
pub locked: Option<bool>,
|
|
pub plottable: Option<bool>,
|
|
pub transparency: Option<codec::types::Transparency>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
/// 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`.
|
|
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::<T>()
|
|
}
|
|
|
|
/// 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::<T>()
|
|
}
|
|
|
|
/// 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::<T>()
|
|
}
|
|
|
|
#[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<String>,
|
|
pub error: Option<String>,
|
|
pub error_type: Option<String>,
|
|
pub traceback: Option<String>,
|
|
pub line_number: Option<u32>,
|
|
pub column_number: Option<u32>,
|
|
pub duration_ms: f64,
|
|
}
|
|
|
|
impl ExecutionResult {
|
|
/// Create a new execution result.
|
|
pub fn new(
|
|
success: bool,
|
|
output: Option<String>,
|
|
error: Option<String>,
|
|
error_type: Option<String>,
|
|
traceback: Option<String>,
|
|
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<F>(
|
|
respond: Box<dyn FnOnce(ExecutionResult) + Send>,
|
|
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::<String>().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};
|