forked from 1b-agent/opencadstudio-upstream
Merge pull request #1423 from felixriestra:pr/python-host-model
The bundled `opencad.python` plugin drives OCS through plugin API v7: all 43 canvas entity kinds, the drawing tables, and OCS's own commands run step by step. RustPython stays in the plugin process — the plugin crate is outside the workspace with its own lock file, so the application's own dependencies are unchanged. Reconciled with the plugin command work that landed as #1425, which the branch was written before: - Both added `impl Drop for PluginProcessInteractiveAdapter`, which git merges silently into two impls that do not compile. One body now cancels the command and then drops it from the runner, in that order: a plugin waiting on a step has to be released before the runner forgets the command. - `execute_command` (#1425) and `run_command` (#1423) are both routes from a script to the command line, and only the second was guarded. The SCRIPTCOMMANDS switch and the denylist are shared now, so neither route is the unguarded one. - `run_command` dispatched with plugin dispatch still enabled, so a script naming a plugin command re-entered the plugin that was blocked waiting for it. It sets the suppression flag for the length of the dispatch, as `execute_command` does. The denylist read the command as typed, while the dispatcher resolves aliases: a user-defined `Q` for `QUIT` (ALIASEDIT writes those) walked past it. It resolves the alias first. Also: the active-command comparison ran on every message, a mouse move included, allocating two strings for names that are already `&'static str`; SCRIPTCOMMANDS' own messages go through the translation macros; and `docs/.DS_Store` (now ignored) and an unrelated RTF note about constraint commands are out of the tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
41ecc0c458
72 files changed
+30614
-91
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,52 @@
|
||||
/// Stable, language-neutral entity capability schema.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EntityScope {
|
||||
Canvas,
|
||||
Internal,
|
||||
Opaque,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ModelAccess {
|
||||
ReadWrite,
|
||||
ReadOnly,
|
||||
Unmapped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PropertyCoverage {
|
||||
/// Name shown in the document model, or source name when unmapped.
|
||||
pub name: String,
|
||||
/// Field in the acadrust snapshot; `kind` is synthesized from the variant.
|
||||
pub source_path: String,
|
||||
pub type_id: String,
|
||||
pub optional: bool,
|
||||
pub is_sequence: bool,
|
||||
/// Whether the serialized host snapshot carries this property.
|
||||
pub snapshot_readable: bool,
|
||||
/// `unmapped` means no normalized Python property or setter; the raw
|
||||
/// snapshot can still expose its serialized value.
|
||||
pub model_access: ModelAccess,
|
||||
/// Write validation applied by the v7 transaction path. Currently
|
||||
/// `transaction_geometry` for selected primitive geometry, otherwise
|
||||
/// `type_conversion_only` for writable properties or `none`.
|
||||
pub validation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EntityKindCoverage {
|
||||
pub kind: String,
|
||||
pub scope: EntityScope,
|
||||
pub properties: Vec<PropertyCoverage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EntityCoverageCatalog {
|
||||
pub schema_version: u32,
|
||||
pub entity_kinds: Vec<EntityKindCoverage>,
|
||||
}
|
||||
@@ -60,6 +60,243 @@ pub enum LogLevel {
|
||||
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)]
|
||||
@@ -76,6 +313,10 @@ pub enum HostNotification {
|
||||
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 },
|
||||
/// Fallback for notification variants added in future minor revisions.
|
||||
/// Carries the raw bincode payload so an older peer can ignore it without
|
||||
/// failing deserialization.
|
||||
@@ -126,6 +367,20 @@ impl Serialize for HostNotification {
|
||||
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::Unknown(raw) => bytes.extend_from_slice(raw),
|
||||
}
|
||||
bytes.serialize(serializer)
|
||||
@@ -163,6 +418,12 @@ impl<'de> Deserialize<'de> for HostNotification {
|
||||
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),
|
||||
_ => Ok(HostNotification::Unknown(bytes)),
|
||||
}
|
||||
}
|
||||
@@ -553,6 +814,62 @@ pub trait HostApi {
|
||||
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.
|
||||
|
||||
@@ -11,7 +11,7 @@ use acadrust::{CadDocument, EntityType, Handle};
|
||||
use interprocess::local_socket::traits::Stream as StreamTrait;
|
||||
use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName};
|
||||
|
||||
use crate::host::{DocumentReader, HostApi, InteractiveCommand, ReaderEntity};
|
||||
use crate::host::{DocumentReader, HostApi, HostSettingValue, InteractiveCommand, ReaderEntity};
|
||||
use crate::ipc::protocol::{
|
||||
HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, RunnerHandshake,
|
||||
};
|
||||
@@ -417,6 +417,104 @@ impl HostApi for PluginHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn system_variable(&self, name: &str) -> Option<HostSettingValue> {
|
||||
match self.client.request(PluginRequest::GetSystemVariable { name: name.to_owned() }) {
|
||||
Ok(PluginResponse::SystemVariable(value)) => value,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_system_variable(
|
||||
&mut self,
|
||||
name: &str,
|
||||
value: HostSettingValue,
|
||||
) -> Result<HostSettingValue, String> {
|
||||
match self.client.request(PluginRequest::SetSystemVariable {
|
||||
name: name.to_owned(),
|
||||
value,
|
||||
}) {
|
||||
Ok(PluginResponse::SystemVariableResult(result)) => {
|
||||
if result.is_ok() {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected system variable response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_entities_transaction(
|
||||
&mut self,
|
||||
label: &str,
|
||||
entities: Vec<EntityType>,
|
||||
) -> Result<(), String> {
|
||||
match self.client.request(PluginRequest::UpdateEntitiesTransaction {
|
||||
label: label.to_owned(), entities,
|
||||
}) {
|
||||
Ok(PluginResponse::EntityTransactionResult(result)) => {
|
||||
if result.is_ok() { self.document_cache = OnceCell::new(); }
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected entity transaction response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn selection(&self) -> Vec<Handle> {
|
||||
match self.client.request(PluginRequest::GetSelection) {
|
||||
Ok(PluginResponse::Selection(handles)) => handles,
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result<Handle, String> {
|
||||
match self.client.request(PluginRequest::SolidOperation { operation }) {
|
||||
Ok(PluginResponse::SolidResult(result)) => {
|
||||
if result.is_ok() { self.document_cache = OnceCell::new(); }
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected solid operation response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_command(&mut self, request: crate::host::CommandRequest) -> Result<crate::host::CommandOutcome, String> {
|
||||
match self.client.request(PluginRequest::RunCommand { request }) {
|
||||
Ok(PluginResponse::CommandResult(result)) => {
|
||||
self.document_cache = OnceCell::new();
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected command response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result<Handle, String> {
|
||||
match self.client.request(PluginRequest::TableOperation { operation }) {
|
||||
Ok(PluginResponse::TableResult(result)) => {
|
||||
if result.is_ok() { self.document_cache = OnceCell::new(); }
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected table operation response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
|
||||
match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
|
||||
Ok(PluginResponse::SelectionResult(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected selection response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_layer(&mut self, config: crate::host::LayerConfig) -> Option<Handle> {
|
||||
match self.client.request(PluginRequest::AddLayer(config)) {
|
||||
Ok(PluginResponse::OptHandle(h)) => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::host::{CommandSource, CommandStep};
|
||||
use crate::host::{CommandSource, CommandStep, HostSettingValue};
|
||||
use crate::manifest::ApiVersion;
|
||||
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup};
|
||||
|
||||
@@ -81,6 +81,8 @@ pub enum HostRequest {
|
||||
code: String,
|
||||
tab_index: usize,
|
||||
},
|
||||
/// V7: release an interactive command after completion or cancellation.
|
||||
DropInteractive { command_id: u64 },
|
||||
}
|
||||
|
||||
/// Responses the plugin runner sends back for `HostRequest`.
|
||||
@@ -149,6 +151,21 @@ pub enum PluginRequest {
|
||||
ModifyLayer(crate::host::LayerConfig),
|
||||
/// Run a command on the active document tab's command line (AutoLISP style).
|
||||
ExecuteCommand(String),
|
||||
/// Read a host-managed setting without nested command dispatch.
|
||||
GetSystemVariable { name: String },
|
||||
/// Change a host-managed setting without nested command dispatch.
|
||||
SetSystemVariable { name: String, value: HostSettingValue },
|
||||
/// V7: validate and replace existing entities in a single undo step.
|
||||
UpdateEntitiesTransaction { label: String, entities: Vec<EntityType> },
|
||||
/// V7: synchronous selection read/write for the dispatch tab.
|
||||
GetSelection,
|
||||
SetSelection { handles: Vec<Handle> },
|
||||
/// V7 (additive): kernel-backed solid create or transform.
|
||||
SolidOperation { operation: crate::host::SolidOperation },
|
||||
/// V7 (additive): drawing table record create/modify/rename/delete.
|
||||
TableOperation { operation: crate::host::TableOperation },
|
||||
/// V7 (additive): drive an OCS command.
|
||||
RunCommand { request: crate::host::CommandRequest },
|
||||
}
|
||||
|
||||
/// Responses the host sends back for `PluginRequest`.
|
||||
@@ -177,6 +194,14 @@ pub enum PluginResponse {
|
||||
DocumentPath(Option<std::ffi::OsString>),
|
||||
/// Optional entity handle (e.g. from AddLayer).
|
||||
OptHandle(Option<Handle>),
|
||||
SystemVariable(Option<HostSettingValue>),
|
||||
SystemVariableResult(Result<HostSettingValue, String>),
|
||||
EntityTransactionResult(Result<(), String>),
|
||||
Selection(Vec<Handle>),
|
||||
SelectionResult(Result<(), String>),
|
||||
SolidResult(Result<Handle, String>),
|
||||
TableResult(Result<Handle, String>),
|
||||
CommandResult(Result<crate::host::CommandOutcome, String>),
|
||||
}
|
||||
|
||||
/// Messages sent from the host to the plugin runner.
|
||||
|
||||
@@ -3,6 +3,27 @@
|
||||
use crate::host::HostApi;
|
||||
use crate::ipc::protocol::{PluginRequest, PluginResponse};
|
||||
|
||||
fn addressable_document_snapshot(host: &dyn HostApi) -> acadrust::CadDocument {
|
||||
let mut snapshot = host.document().clone();
|
||||
let attributes = host
|
||||
.document()
|
||||
.entities()
|
||||
.filter_map(|entity| match entity {
|
||||
acadrust::EntityType::Insert(insert) => Some(insert.attributes.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.map(acadrust::EntityType::AttributeEntity)
|
||||
.collect::<Vec<_>>();
|
||||
for attribute in attributes {
|
||||
// Attribute entities remain canonical children of INSERT in the host
|
||||
// document. The IPC snapshot also indexes them by handle so plugin
|
||||
// document models can address a child exactly like any other entity.
|
||||
let _ = snapshot.add_entity(attribute);
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Apply one plugin request to the host's `HostApi` implementation.
|
||||
///
|
||||
/// `on_start_interactive` is called when the plugin starts an interactive
|
||||
@@ -54,7 +75,7 @@ pub fn handle_plugin_request(
|
||||
on_start_interactive(command_id);
|
||||
PluginResponse::Ok
|
||||
}
|
||||
DocumentSnapshot => PluginResponse::Document(Box::new(host.document().clone())),
|
||||
DocumentSnapshot => PluginResponse::Document(Box::new(addressable_document_snapshot(host))),
|
||||
OpenDocumentView => match host.document_view() {
|
||||
Some(info) => PluginResponse::DocumentView {
|
||||
path: info.path,
|
||||
@@ -80,5 +101,17 @@ pub fn handle_plugin_request(
|
||||
AddLayer(config) => PluginResponse::OptHandle(host.add_layer(config)),
|
||||
ModifyLayer(config) => PluginResponse::Bool(host.modify_layer(config)),
|
||||
ExecuteCommand(cmd) => PluginResponse::Bool(host.execute_command(&cmd)),
|
||||
GetSystemVariable { name } => PluginResponse::SystemVariable(host.system_variable(&name)),
|
||||
SetSystemVariable { name, value } => {
|
||||
PluginResponse::SystemVariableResult(host.set_system_variable(&name, value))
|
||||
}
|
||||
UpdateEntitiesTransaction { label, entities } => PluginResponse::EntityTransactionResult(
|
||||
host.update_entities_transaction(&label, entities),
|
||||
),
|
||||
GetSelection => PluginResponse::Selection(host.selection()),
|
||||
SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)),
|
||||
SolidOperation { operation } => PluginResponse::SolidResult(host.solid_operation(operation)),
|
||||
TableOperation { operation } => PluginResponse::TableResult(host.table_operation(operation)),
|
||||
RunCommand { request } => PluginResponse::CommandResult(host.run_command(request)),
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use interprocess::TryClone;
|
||||
|
||||
use crate::host::{
|
||||
DocumentReader, ExecutionResult, HostApi, HostNotification, InteractiveCommand,
|
||||
PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity,
|
||||
PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity, HostSettingValue,
|
||||
};
|
||||
use crate::ipc::protocol::{
|
||||
HostRequest, HostResponse, PluginRequest, PluginResponse, RunnerHandshake,
|
||||
@@ -731,6 +731,104 @@ impl HostApi for V4PluginHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn system_variable(&self, name: &str) -> Option<HostSettingValue> {
|
||||
match self.request(PluginRequest::GetSystemVariable { name: name.to_owned() }) {
|
||||
Ok(PluginResponse::SystemVariable(value)) => value,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_system_variable(
|
||||
&mut self,
|
||||
name: &str,
|
||||
value: HostSettingValue,
|
||||
) -> Result<HostSettingValue, String> {
|
||||
match self.request(PluginRequest::SetSystemVariable {
|
||||
name: name.to_owned(),
|
||||
value,
|
||||
}) {
|
||||
Ok(PluginResponse::SystemVariableResult(result)) => {
|
||||
if result.is_ok() {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected system variable response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn update_entities_transaction(
|
||||
&mut self,
|
||||
label: &str,
|
||||
entities: Vec<EntityType>,
|
||||
) -> Result<(), String> {
|
||||
match self.request(PluginRequest::UpdateEntitiesTransaction {
|
||||
label: label.to_owned(), entities,
|
||||
}) {
|
||||
Ok(PluginResponse::EntityTransactionResult(result)) => {
|
||||
if result.is_ok() { self.document_cache = OnceCell::new(); }
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected entity transaction response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn selection(&self) -> Vec<Handle> {
|
||||
match self.request(PluginRequest::GetSelection) {
|
||||
Ok(PluginResponse::Selection(handles)) => handles,
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result<Handle, String> {
|
||||
match self.request(PluginRequest::SolidOperation { operation }) {
|
||||
Ok(PluginResponse::SolidResult(result)) => {
|
||||
if result.is_ok() { self.document_cache = OnceCell::new(); }
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected solid operation response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_command(&mut self, request: crate::host::CommandRequest) -> Result<crate::host::CommandOutcome, String> {
|
||||
match self.request(PluginRequest::RunCommand { request }) {
|
||||
Ok(PluginResponse::CommandResult(result)) => {
|
||||
self.document_cache = OnceCell::new();
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected command response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result<Handle, String> {
|
||||
match self.request(PluginRequest::TableOperation { operation }) {
|
||||
Ok(PluginResponse::TableResult(result)) => {
|
||||
if result.is_ok() { self.document_cache = OnceCell::new(); }
|
||||
result
|
||||
}
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected table operation response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
|
||||
match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
|
||||
Ok(PluginResponse::SelectionResult(result)) => result,
|
||||
Ok(PluginResponse::Error(error)) => Err(error),
|
||||
Ok(other) => Err(format!("unexpected selection response: {other:?}")),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_layer(&mut self, config: crate::host::LayerConfig) -> Option<Handle> {
|
||||
match self.request(PluginRequest::AddLayer(config)) {
|
||||
Ok(PluginResponse::OptHandle(h)) => {
|
||||
|
||||
@@ -86,6 +86,167 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_state_notification_roundtrips() {
|
||||
let notification = HostNotification::CommandStateChanged {
|
||||
tab_id: 42,
|
||||
command: Some("LINE".to_owned()),
|
||||
};
|
||||
let bytes = bincode::serialize(¬ification).unwrap();
|
||||
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
|
||||
assert_eq!(decoded, notification);
|
||||
let stopped = HostNotification::CommandStateChanged { tab_id: 42, command: None };
|
||||
let bytes = bincode::serialize(&stopped).unwrap();
|
||||
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
|
||||
assert_eq!(decoded, stopped);
|
||||
let changed = HostNotification::DrawingChanged { tab_id: 42, epoch: 7 };
|
||||
let bytes = bincode::serialize(&changed).unwrap();
|
||||
let decoded: HostNotification = bincode::deserialize(&bytes).unwrap();
|
||||
assert_eq!(decoded, changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_request_and_outcome_roundtrip() {
|
||||
use crate::host::{CommandOutcome, CommandRequest};
|
||||
for request in [
|
||||
CommandRequest::Run { line: "LINE 0,0 1,1".into() },
|
||||
CommandRequest::Start { name: "OFFSET".into() },
|
||||
CommandRequest::Point { point: [1.0, 2.0, 3.0] },
|
||||
CommandRequest::Text { text: "2".into() },
|
||||
CommandRequest::Token { text: "R".into() },
|
||||
CommandRequest::Entity { handle: acadrust::Handle::new(9), point: [0.0; 3] },
|
||||
CommandRequest::Selection,
|
||||
CommandRequest::Enter,
|
||||
CommandRequest::Cancel,
|
||||
] {
|
||||
let bytes = bincode::serialize(&PluginRequest::RunCommand { request: request.clone() }).unwrap();
|
||||
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::RunCommand { request: decoded } if decoded == request));
|
||||
}
|
||||
let outcome = CommandOutcome {
|
||||
status: "waiting_input".into(),
|
||||
blocked_by: Some("command".into()),
|
||||
command: "OFFSET".into(),
|
||||
prompt: "Select object".into(),
|
||||
accepts: vec!["entity".into()],
|
||||
options: vec!["M".into()],
|
||||
entities: 4,
|
||||
added: -1,
|
||||
unconsumed: vec!["x".into()],
|
||||
error: Some("e".into()),
|
||||
};
|
||||
for result in [Ok(outcome), Err("refused".to_owned())] {
|
||||
let bytes = bincode::serialize(&PluginResponse::CommandResult(result.clone())).unwrap();
|
||||
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::CommandResult(decoded) if decoded == result));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_operation_request_and_response_roundtrip() {
|
||||
use crate::host::{LayerConfig, TableOperation};
|
||||
for operation in [
|
||||
TableOperation::LayerCreate {
|
||||
config: LayerConfig {
|
||||
name: "Walls".into(),
|
||||
color: Some(acadrust::types::Color::Index(5)),
|
||||
frozen: Some(true),
|
||||
description: Some("d".into()),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
TableOperation::LayerModify { config: LayerConfig { name: "Walls".into(), off: Some(true), ..Default::default() } },
|
||||
TableOperation::LayerRename { from: "A".into(), to: "B".into() },
|
||||
TableOperation::LayerDelete { name: "A".into(), erase_objects: true },
|
||||
TableOperation::LayerSetCurrent { name: "A".into() },
|
||||
TableOperation::TextStyleCreate {
|
||||
config: crate::host::TextStyleConfig { name: "T".into(), height: Some(2.5), oblique_angle: Some(0.2), backward: Some(true), ..Default::default() },
|
||||
},
|
||||
TableOperation::TextStyleModify { config: crate::host::TextStyleConfig { name: "T".into(), font_file: Some("romans".into()), ..Default::default() } },
|
||||
TableOperation::DimStyleCreate { name: "D".into(), copy_from: Some("Standard".into()), properties: "{\"dimscale\":2}".into() },
|
||||
TableOperation::DimStyleModify { name: "D".into(), properties: "{}".into() },
|
||||
TableOperation::StyleRename { kind: crate::host::TableStyleKind::Text, from: "A".into(), to: "B".into() },
|
||||
TableOperation::StyleDelete { kind: crate::host::TableStyleKind::Dim, name: "A".into() },
|
||||
TableOperation::StyleSetCurrent { kind: crate::host::TableStyleKind::Dim, name: "A".into() },
|
||||
TableOperation::BlockCreate {
|
||||
name: "B".into(),
|
||||
entities: vec![acadrust::Handle::new(4), acadrust::Handle::new(5)],
|
||||
base_point: [1.0, 2.0, 3.0],
|
||||
erase_originals: true,
|
||||
description: Some("d".into()),
|
||||
},
|
||||
TableOperation::BlockModify { name: "B".into(), description: None, explodable: Some(false), scale_uniformly: Some(true) },
|
||||
TableOperation::BlockRename { from: "B".into(), to: "C".into() },
|
||||
TableOperation::BlockDelete { name: "C".into() },
|
||||
TableOperation::BlockEntityAdd {
|
||||
block: "C".into(),
|
||||
entity: acadrust::EntityType::Line(acadrust::entities::Line::new()),
|
||||
},
|
||||
TableOperation::LinetypeCreate { name: "L".into(), description: "d".into(), pattern: vec![1.0, -1.0] },
|
||||
TableOperation::LinetypeModify { name: "L".into(), description: None, pattern: Some(vec![2.0, -2.0]) },
|
||||
TableOperation::LinetypeRename { from: "L".into(), to: "M".into() },
|
||||
TableOperation::LinetypeDelete { name: "M".into() },
|
||||
TableOperation::LayoutCreate { name: "S".into() },
|
||||
TableOperation::LayoutRename { from: "S".into(), to: "T".into() },
|
||||
TableOperation::LayoutDelete { name: "T".into() },
|
||||
TableOperation::LayoutSetCurrent { name: "Model".into() },
|
||||
TableOperation::LayoutSetPage { name: "S".into(), paper_size: Some([420.0, 297.0]), rotation: Some(90), scale: Some([1.0, 50.0]) },
|
||||
] {
|
||||
let bytes = bincode::serialize(&PluginRequest::TableOperation { operation: operation.clone() }).unwrap();
|
||||
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::TableOperation { operation: decoded } if decoded == operation));
|
||||
}
|
||||
for result in [Ok(acadrust::Handle::new(9)), Err("refused".to_owned())] {
|
||||
let bytes = bincode::serialize(&PluginResponse::TableResult(result.clone())).unwrap();
|
||||
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::TableResult(decoded) if decoded == result));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_operation_request_and_response_roundtrip() {
|
||||
use crate::host::{SolidOperation, SolidPrimitive};
|
||||
for operation in [
|
||||
SolidOperation::Create {
|
||||
primitive: SolidPrimitive::Pyramid { center: [1.0, 2.0, 3.0], radius: 4.0, height: 5.0, sides: 6 },
|
||||
layer: Some("SOLIDS".into()),
|
||||
},
|
||||
SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] },
|
||||
SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), layer: None, delete_source: true },
|
||||
SolidOperation::EmbedPicture { path: "p.png".into(), origin: [0.0; 3], width: 5.0, layer: None },
|
||||
SolidOperation::Boolean {
|
||||
first: acadrust::Handle::new(1),
|
||||
second: acadrust::Handle::new(2),
|
||||
operation: crate::host::SolidBoolean::Subtract,
|
||||
layer: None,
|
||||
keep_operands: false,
|
||||
},
|
||||
SolidOperation::SurfaceFromProfile { source: acadrust::Handle::new(4), layer: Some("S".into()), delete_source: false },
|
||||
SolidOperation::Extrude { source: acadrust::Handle::new(5), direction: [0.0, 0.0, 2.0], layer: None, delete_source: true },
|
||||
] {
|
||||
let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap();
|
||||
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::SolidOperation { operation: decoded } if decoded == operation));
|
||||
}
|
||||
for result in [Ok(acadrust::Handle::new(9)), Err("refused".to_owned())] {
|
||||
let bytes = bincode::serialize(&PluginResponse::SolidResult(result.clone())).unwrap();
|
||||
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::SolidResult(decoded) if decoded == result));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_request_and_response_roundtrip() {
|
||||
let request = PluginRequest::SetSelection { handles: vec![acadrust::Handle::new(9)] };
|
||||
let bytes = bincode::serialize(&request).unwrap();
|
||||
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
|
||||
PluginRequest::SetSelection { handles } if handles == vec![acadrust::Handle::new(9)]));
|
||||
let response = PluginResponse::Selection(vec![acadrust::Handle::new(9)]);
|
||||
let bytes = bincode::serialize(&response).unwrap();
|
||||
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
|
||||
PluginResponse::Selection(handles) if handles == vec![acadrust::Handle::new(9)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_notification_roundtrip() {
|
||||
let frame = PluginToHostV4::Notification(NotificationEnvelope {
|
||||
|
||||
@@ -36,6 +36,8 @@ pub mod manifest;
|
||||
pub mod ribbon;
|
||||
pub mod type_registry;
|
||||
pub mod type_registry_types;
|
||||
pub mod entity_coverage;
|
||||
pub mod entity_coverage_types;
|
||||
pub mod version_info;
|
||||
|
||||
/// Runtime host surface — only built with the `host` feature (pulls `acadrust`).
|
||||
@@ -73,6 +75,8 @@ pub use type_registry::{
|
||||
get_embedded_type_registry_json, EnumVariantInfo, FieldInfo, MethodInfo, ParameterInfo,
|
||||
TypeId, TypeInfo, TypeKind, TypeRegistry,
|
||||
};
|
||||
pub use entity_coverage::{get_embedded_entity_coverage_json, EntityCoverageCatalog,
|
||||
EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage};
|
||||
pub use version_info::get_embedded_version_info_json;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
/// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on
|
||||
/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5
|
||||
/// adds the `BuiltinPlugin::on_load` lifecycle callback and
|
||||
/// `HostApi::document_path`.
|
||||
pub const API_VERSION: u32 = 5;
|
||||
/// `HostApi::document_path`. v6 appends generic host system-variable access
|
||||
/// (`HostApi::system_variable` / `set_system_variable`). v7 appends atomic,
|
||||
/// undoable replacement of a set of existing entities.
|
||||
pub const API_VERSION: u32 = 7;
|
||||
|
||||
/// Oldest plugin API major the current host still loads. This keeps previously
|
||||
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
|
||||
@@ -28,7 +30,7 @@ pub const API_VERSION: u32 = 5;
|
||||
pub const API_VERSION_MIN_SUPPORTED: u32 = 2;
|
||||
|
||||
/// Environment variable that caps the API major accepted by the host at
|
||||
/// runtime. Set to `4` to disable V5 plugins, `3` to disable V4, or `2` for
|
||||
/// runtime. Set to `5` to disable V6 plugins, `4` to disable V5, or `2` for
|
||||
/// V2-only mode.
|
||||
pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION";
|
||||
|
||||
|
||||
@@ -237,6 +237,7 @@ pub(crate) fn request_kind(req: &HostRequest) -> &'static str {
|
||||
HostRequest::GetPrompt { .. } => "GetPrompt",
|
||||
HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick",
|
||||
HostRequest::ExecuteCode { .. } => "ExecuteCode",
|
||||
HostRequest::DropInteractive { .. } => "DropInteractive",
|
||||
HostRequest::Shutdown => "Shutdown",
|
||||
}
|
||||
}
|
||||
@@ -621,6 +622,15 @@ impl PluginProcess {
|
||||
}
|
||||
}
|
||||
|
||||
/// Release a completed or abandoned V4 interactive command in the runner.
|
||||
pub fn drop_interactive(&self, command_id: u64) -> Result<(), PluginError> {
|
||||
let Some(v4) = &self.v4 else { return Ok(()); };
|
||||
match v4.call(&mut NullHost, HostRequest::DropInteractive { command_id }, &mut |_| {})? {
|
||||
HostResponse::Bool(true) => Ok(()),
|
||||
other => Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the plugin process for the current prompt of an interactive command.
|
||||
pub fn get_prompt(&self, command_id: u64) -> Result<String, PluginError> {
|
||||
if let Some(v4) = &self.v4 {
|
||||
|
||||
@@ -16,8 +16,7 @@ use crate::ipc::v4::protocol::{HostToPluginV4, NotificationEnvelope};
|
||||
use crate::ipc::v4::server::{
|
||||
default_notify_rate_limit, run_host_reader_thread, HostIncoming, RateLimiter, V4HostShared,
|
||||
};
|
||||
use crate::process::PluginError;
|
||||
use crate::process::{request_kind, request_timeout};
|
||||
use crate::process::{request_kind, request_timeout, PluginError};
|
||||
|
||||
type DeferredRequest = (u64, Option<u64>, Box<PluginRequest>);
|
||||
|
||||
@@ -387,16 +386,21 @@ mod tests {
|
||||
|
||||
struct DummyHost {
|
||||
push_info_messages: StdMutex<Vec<String>>,
|
||||
transactions: StdMutex<Vec<(String, Vec<acadrust::EntityType>)>>,
|
||||
}
|
||||
impl DummyHost {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
push_info_messages: StdMutex::new(Vec::new()),
|
||||
transactions: StdMutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
fn take_push_info(&self) -> Vec<String> {
|
||||
std::mem::take(&mut *self.push_info_messages.lock().unwrap())
|
||||
}
|
||||
fn take_transactions(&self) -> Vec<(String, Vec<acadrust::EntityType>)> {
|
||||
std::mem::take(&mut *self.transactions.lock().unwrap())
|
||||
}
|
||||
}
|
||||
impl HostApi for DummyHost {
|
||||
fn tab_index(&self) -> usize {
|
||||
@@ -433,6 +437,14 @@ mod tests {
|
||||
false
|
||||
}
|
||||
fn push_undo(&mut self, _label: &str) {}
|
||||
fn update_entities_transaction(
|
||||
&mut self,
|
||||
label: &str,
|
||||
entities: Vec<acadrust::EntityType>,
|
||||
) -> Result<(), String> {
|
||||
self.transactions.lock().unwrap().push((label.to_owned(), entities));
|
||||
Ok(())
|
||||
}
|
||||
fn set_dirty(&mut self) {}
|
||||
fn push_info(&mut self, msg: &str) {
|
||||
self.push_info_messages.lock().unwrap().push(msg.to_string());
|
||||
@@ -604,6 +616,49 @@ mod tests {
|
||||
restore_test_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_connection_routes_entity_transaction_over_nested_ipc() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
set_test_env();
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||
Arc::new(|_, _| {});
|
||||
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||
let mut point = acadrust::entities::Point::new();
|
||||
point.common.handle = Handle::new(42);
|
||||
point.location.x = 7.0;
|
||||
let expected = acadrust::EntityType::Point(point);
|
||||
let sent = expected.clone();
|
||||
|
||||
let runner = thread::spawn(move || {
|
||||
let req = recv::<HostToPluginV4>(&mut runner_stream).unwrap();
|
||||
let HostToPluginV4::Request { id, payload: HostRequest::Dispatch { .. } } = req else {
|
||||
panic!("unexpected host request: {req:?}");
|
||||
};
|
||||
send(&mut runner_stream, &PluginToHostV4::Request {
|
||||
id: 99,
|
||||
tab_id: Some(0),
|
||||
payload: PluginRequest::UpdateEntitiesTransaction {
|
||||
label: "Move point".into(),
|
||||
entities: vec![sent],
|
||||
},
|
||||
}).unwrap();
|
||||
assert!(matches!(recv::<HostToPluginV4>(&mut runner_stream).unwrap(),
|
||||
HostToPluginV4::Response { id: 99, payload: PluginResponse::EntityTransactionResult(Ok(())) }));
|
||||
send(&mut runner_stream, &PluginToHostV4::Response {
|
||||
id,
|
||||
payload: HostResponse::Bool(true),
|
||||
}).unwrap();
|
||||
});
|
||||
|
||||
let mut host = DummyHost::new();
|
||||
assert!(matches!(conn.call(&mut host, HostRequest::Dispatch { cmd: "MOVE".into() }, &mut |_| {}).unwrap(),
|
||||
HostResponse::Bool(true)));
|
||||
assert_eq!(host.take_transactions(), vec![("Move point".into(), vec![expected])]);
|
||||
runner.join().unwrap();
|
||||
restore_test_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_connection_notification_invokes_handler() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
|
||||
@@ -211,6 +211,10 @@ fn handle_host_request(
|
||||
Err(_) => HostResponse::Error("interactive command panicked".to_string()),
|
||||
}
|
||||
}
|
||||
HostRequest::DropInteractive { command_id } => {
|
||||
interactive.borrow_mut().remove(&command_id);
|
||||
HostResponse::Bool(true)
|
||||
}
|
||||
HostRequest::GetPrompt { command_id } => {
|
||||
let result = {
|
||||
let registry = interactive.borrow();
|
||||
@@ -330,6 +334,10 @@ fn handle_host_request_v4(
|
||||
Err(_) => Some(HostResponse::Error("interactive command panicked".to_string())),
|
||||
}
|
||||
}
|
||||
HostRequest::DropInteractive { command_id } => {
|
||||
interactive.borrow_mut().remove(&command_id);
|
||||
Some(HostResponse::Bool(true))
|
||||
}
|
||||
HostRequest::GetPrompt { command_id } => {
|
||||
let result = {
|
||||
let registry = interactive.borrow();
|
||||
|
||||
@@ -83,13 +83,19 @@ impl<T: SnapshotData> DocumentSnapshotStore<T> {
|
||||
/// page.
|
||||
pub fn new(tab_id: u64, segment_size: usize) -> io::Result<Self> {
|
||||
let segment_size = segment_size.checked_next_multiple_of(4096).ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "snapshot segment size overflow")
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"snapshot segment size overflow",
|
||||
)
|
||||
})?;
|
||||
let total = segment_size
|
||||
.checked_mul(2)
|
||||
.and_then(|segments| CONTROL_SIZE.checked_add(segments))
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, "snapshot mapping size overflow")
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"snapshot mapping size overflow",
|
||||
)
|
||||
})?;
|
||||
if total > MAX_SNAPSHOT_SIZE {
|
||||
return Err(io::Error::new(
|
||||
@@ -245,7 +251,10 @@ impl<T: SnapshotData> SharedDocumentReader<T> {
|
||||
));
|
||||
}
|
||||
let file_len = usize::try_from(file_len).map_err(|_| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, "snapshot file size is unsupported")
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"snapshot file size is unsupported",
|
||||
)
|
||||
})?;
|
||||
let mmap = unsafe { MmapOptions::new().len(file_len).map(&file)? };
|
||||
let segment_size = (file_len - CONTROL_SIZE) / 2;
|
||||
@@ -543,10 +552,22 @@ pub struct DocumentViewDataV4 {
|
||||
|
||||
impl From<&CadDocument> for DocumentViewDataV4 {
|
||||
fn from(doc: &CadDocument) -> Self {
|
||||
let mut entities: Vec<_> = doc.entities().map(EntityViewV4::from).collect();
|
||||
// ATTRIB records live inside their owning INSERT in CadDocument rather
|
||||
// than in the flat entity index. Flatten them into the plugin snapshot
|
||||
// as addressable entities while keeping the INSERT's canonical nested
|
||||
// copy intact for rendering and file serialization.
|
||||
for entity in doc.entities() {
|
||||
if let EntityType::Insert(insert) = entity {
|
||||
entities.extend(insert.attributes.iter().map(|attribute| {
|
||||
EntityViewV4::from(&EntityType::AttributeEntity(attribute.clone()))
|
||||
}));
|
||||
}
|
||||
}
|
||||
Self {
|
||||
layers: doc.layers.iter().map(LayerView::from).collect(),
|
||||
app_ids: doc.app_ids.iter().map(AppIdView::from).collect(),
|
||||
entities: doc.entities().map(EntityViewV4::from).collect(),
|
||||
entities,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -721,7 +742,10 @@ mod tests {
|
||||
|
||||
let archived = reader.archived().unwrap();
|
||||
let entity = &archived.entities[0];
|
||||
assert_eq!(entity.handle, doc.entities().next().unwrap().common().handle.value());
|
||||
assert_eq!(
|
||||
entity.handle,
|
||||
doc.entities().next().unwrap().common().handle.value()
|
||||
);
|
||||
let decoded: EntityType = bincode::deserialize(&entity.data).expect("bincode decode");
|
||||
assert!(matches!(decoded, EntityType::Point(_)));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user