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:
Hakan SevenandClaude Opus 5 committed 2026-09-22 23:26:30 +03:00
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>,
}
+317
View File
@@ -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.
+99 -1
View File
@@ -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)) => {
+26 -1
View File
@@ -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.
+34 -1
View File
@@ -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)),
}
}
+99 -1
View File
@@ -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(&notification).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 {
+4
View File
@@ -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)]
+5 -3
View File
@@ -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";
+10
View File
@@ -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 {
+57 -2
View File
@@ -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();
+8
View File
@@ -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();
+29 -5
View File
@@ -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(_)));
}