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

+4 -2
View File
@@ -44,12 +44,14 @@ The host and each plugin run in separate OS processes. The host re-executes itse
## Versioning and ABI stability
- `API_VERSION` (currently `5`) is the host's advertised major.
- `API_VERSION` (currently `6`) is the host's advertised major.
- `API_VERSION_MIN_SUPPORTED` (currently `2`) is the oldest plugin major the host loads.
- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `4` to disable V5).
- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `5` to disable V6).
- A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end.
- V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)).
- V5 introduces `BuiltinPlugin::on_load` and tab-keyed document paths.
- V6 appends typed system-variable access to `HostApi`; the host currently
supports `CLAYER` and `SNAPANG` (plus a read-only `CTAB`).
The runtime enforces three gates:
+2 -1
View File
@@ -10,6 +10,7 @@ license = "GPL-3.0-only"
# registry types (`type_registry_types.rs`). The host feature no longer needs
# to enable it explicitly.
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine
@@ -30,7 +31,7 @@ getrandom = { version = "0.2", optional = true }
# Enables the runtime host surface (`HostApi` trait) and the out-of-process
# plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers
# leave it off.
host = ["dep:acadrust", "dep:interprocess", "dep:bincode", "dep:serde_bytes", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"]
host = ["dep:acadrust", "dep:serde_json", "dep:interprocess", "dep:bincode", "dep:serde_bytes", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"]
[build-dependencies]
serde-reflection = "0.6"
+8 -3
View File
@@ -9,7 +9,7 @@ cheaply. The runtime host surface is enabled by the `host` feature.
## Plugin Architecture
OCS is scriptable and extensible through a versioned plugin API. The API
supports four major generations: **V2**, **V3**, **V4**, and **V5**. Each
supports five major generations: **V2**, **V3**, **V4**, **V5**, and **V6**. Each
generation keeps the previous ABI stable by appending new vtable entries and new
enum variants at the end, so older plugins continue to load on newer hosts.
@@ -19,10 +19,11 @@ enum variants at the end, so older plugins continue to load on newer hosts.
| **V3** | Out-of-process runner | Local cached `document()`/`document_mut()` + shared-memory `DocumentReader`/`document_view` for large reads | New vtable entries appended; new `PluginRequest`/`PluginResponse` variants appended |
| **V4** | Out-of-process runner over a multiplexed local socket | Full-duplex notifications + asynchronous REPL `ExecuteCode` | New `HostToPluginV4`/`PluginToHostV4` frame layer; `BuiltinPlugin::on_notification` and `start_execute_code` appended to trait |
| **V5** | V4 multiplexed runner | One-shot initialization + tab-keyed document paths | `BuiltinPlugin::on_load` and `HostApi::document_path` appended to the traits |
| **V6** | V4 multiplexed runner | Read and write supported host system variables | `HostApi::system_variable` and `HostApi::set_system_variable` appended to the trait; request/response variants appended |
The host controls which generations are accepted at runtime through
`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `4` to disable V5). The
current host advertises [`API_VERSION = 5`](src/manifest.rs) and supports plugins
`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `5` to disable V6). The
current host advertises [`API_VERSION = 6`](src/manifest.rs) and supports plugins
back to `API_VERSION_MIN_SUPPORTED = 2`.
V5 calls `BuiltinPlugin::on_load` once after the runner connects and before the
@@ -30,6 +31,10 @@ first user command. Plugins can cache `HostApi::plugin_request_sender()` there
for worker-thread access. A panic in the callback fails plugin loading.
`HostApi::document_path(tab_id)` returns the saved path for a document tab.
V6 adds a typed system-variable interface. This fork currently implements
`CLAYER` (current drawing layer) and `SNAPANG` (snap-angle degrees). Unknown
variables return an error rather than silently accepting a value.
### High-level layout
```mermaid
+376 -1
View File
@@ -14,6 +14,32 @@ use serde_reflection::{
// Include the stable schema types so the same definitions are used at build
// time and at runtime. The file is self-contained and only depends on serde.
include!("src/type_registry_types.rs");
mod entity_coverage_schema {
include!("src/entity_coverage_types.rs");
}
use entity_coverage_schema::{
EntityCoverageCatalog, EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage,
};
#[derive(serde::Deserialize)]
struct SyntheticProperty {
name: String,
type_id: String,
access: String,
}
#[derive(serde::Deserialize)]
struct EntityCoveragePolicy {
internal_kinds: Vec<String>,
opaque_kinds: Vec<String>,
editable: BTreeMap<String, Vec<String>>,
readable: BTreeMap<String, Vec<String>>,
aliases: BTreeMap<String, BTreeMap<String, String>>,
/// Properties for kinds whose payload is a nested enum (Dimension), where
/// the traced variant has no single field list to map.
#[serde(default)]
synthetic: BTreeMap<String, Vec<SyntheticProperty>>,
}
fn main() {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
@@ -23,6 +49,8 @@ fn main() {
"cargo:rerun-if-changed={}",
workspace_cargo_lock_path().display()
);
println!("cargo:rerun-if-changed=entity_coverage_policy.json");
println!("cargo:rerun-if-changed=src/entity_coverage_types.rs");
}
// ════════════════════════════════════════════════════════════════════════════
@@ -486,6 +514,353 @@ fn generate_type_registry(out_dir: &Path) {
);
let json = serde_json::to_string_pretty(&registry).unwrap();
fs::write(out_dir.join("type_registry.json"), json).unwrap();
generate_entity_coverage(out_dir, &registry);
}
fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) {
use std::collections::HashSet;
let policy: EntityCoveragePolicy =
serde_json::from_str(include_str!("entity_coverage_policy.json"))
.expect("valid entity coverage policy");
let variants = &registry.types[&TypeId::new("EntityType")].variants;
let variant_names: HashSet<&str> = variants.iter().map(|v| v.name.as_str()).collect();
for name in policy
.internal_kinds
.iter()
.chain(&policy.opaque_kinds)
.chain(policy.editable.keys())
.chain(policy.readable.keys())
{
assert!(
variant_names.contains(name.as_str()),
"coverage policy names unknown kind: {name}"
);
}
for name in &policy.internal_kinds {
assert!(
!policy.opaque_kinds.contains(name) && !policy.editable.contains_key(name),
"internal kind has conflicting coverage: {name}"
);
}
for name in &policy.opaque_kinds {
assert!(
!policy.editable.contains_key(name),
"opaque kind is editable: {name}"
);
}
for name in policy.aliases.keys() {
assert!(
policy.editable.contains_key(name),
"aliases for non-editable kind: {name}"
);
}
let common = &registry.types[&TypeId::new("EntityCommon")];
let mut entity_kinds = Vec::with_capacity(variants.len());
for variant in variants {
let kind = &variant.name;
let scope = if policy.internal_kinds.contains(kind) {
EntityScope::Internal
} else if policy.opaque_kinds.contains(kind) {
EntityScope::Opaque
} else {
EntityScope::Canvas
};
let editable = policy.editable.get(kind).cloned().unwrap_or_default();
let readable = policy.readable.get(kind).cloned().unwrap_or_default();
let mut unresolved: HashSet<String> = editable.iter().chain(&readable).cloned().collect();
assert_eq!(
unresolved.len(),
editable.len() + readable.len(),
"duplicate mapped property in {kind}"
);
let aliases = policy.aliases.get(kind);
let mut properties = vec![PropertyCoverage {
name: "kind".into(),
source_path: "<variant>".into(),
type_id: "String".into(),
optional: false,
is_sequence: false,
snapshot_readable: true,
model_access: ModelAccess::ReadOnly,
validation: "none".into(),
}];
for field in &common.fields {
let (name, access) = match field.name.as_str() {
"handle" => ("handle".to_owned(), ModelAccess::ReadOnly),
"owner_handle" => ("owner_handle".to_owned(), ModelAccess::ReadOnly),
"layer" if scope == EntityScope::Canvas => {
unresolved.remove("layer");
("layer".to_owned(), ModelAccess::ReadWrite)
}
"layer" => ("layer".to_owned(), ModelAccess::ReadOnly),
other => (format!("common.{other}"), ModelAccess::Unmapped),
};
properties.push(PropertyCoverage {
name,
source_path: format!("common.{}", field.name),
type_id: field.type_id.as_str().to_owned(),
optional: field.optional,
is_sequence: field.is_sequence,
snapshot_readable: true,
validation: if access == ModelAccess::ReadWrite && field.name == "layer" {
"transaction_nonempty"
} else if access == ModelAccess::ReadWrite {
"type_conversion_only"
} else {
"none"
}
.into(),
model_access: access,
});
}
let shape_type = variant
.fields
.first()
.expect("entity variant payload")
.type_id
.clone();
let shape = &registry.types[&shape_type];
for field in &shape.fields {
if field.name == "common" {
continue;
}
let exposed = editable.iter().chain(&readable).find(|name| {
aliases
.and_then(|map| map.get(*name))
.map_or(name.as_str(), String::as_str)
== field.name
});
let (name, access) = match exposed {
Some(name) => {
unresolved.remove(name);
(
(*name).clone(),
if editable.contains(name) {
ModelAccess::ReadWrite
} else {
ModelAccess::ReadOnly
},
)
}
None => (field.name.clone(), ModelAccess::Unmapped),
};
let validation = if access != ModelAccess::ReadWrite {
"none"
} else if matches!(
(kind.as_str(), name.as_str()),
("Point", "location")
| ("Line", "start" | "end")
| ("Circle" | "Arc", "center" | "radius")
| ("Ray" | "XLine", "base_point" | "direction")
| (
"Solid",
"first_corner"
| "second_corner"
| "third_corner"
| "fourth_corner"
| "normal"
| "thickness"
)
| (
"Face3D",
"first_corner"
| "second_corner"
| "third_corner"
| "fourth_corner"
| "invisible_edges"
)
| (
"Insert",
"insert_point"
| "x_scale"
| "y_scale"
| "z_scale"
| "rotation"
| "normal"
| "column_count"
| "row_count"
| "column_spacing"
| "row_spacing"
)
| (
"Tolerance",
"insertion_point"
| "direction"
| "normal"
| "text"
| "dimension_style_name"
| "text_height"
| "dimension_gap"
)
| (
"Shape",
"insertion_point"
| "size"
| "shape_name"
| "shape_number"
| "rotation"
| "relative_x_scale"
| "oblique_angle"
| "normal"
| "thickness"
| "style_name"
)
| (
"AttributeDefinition",
"tag"
| "prompt"
| "default_value"
| "insertion_point"
| "alignment_point"
| "height"
| "rotation"
| "width_factor"
| "oblique_angle"
| "text_style"
| "text_generation_flags"
| "horizontal_alignment"
| "vertical_alignment"
| "flags"
| "field_length"
| "normal"
| "mtext_flag"
| "is_multiline"
| "line_count"
| "lock_position"
)
| (
"AttributeEntity",
"tag"
| "value"
| "insertion_point"
| "alignment_point"
| "height"
| "rotation"
| "width_factor"
| "oblique_angle"
| "text_style"
| "text_generation_flags"
| "horizontal_alignment"
| "vertical_alignment"
| "flags"
| "field_length"
| "normal"
| "mtext_flag"
| "is_multiline"
| "line_count"
| "lock_position"
)
| (
"Hatch",
"elevation"
| "normal"
| "is_solid"
| "pattern"
| "pattern_angle"
| "pattern_scale"
| "pattern_type"
| "is_double"
| "style"
| "is_associative"
| "pixel_size"
| "paths"
| "seed_points"
)
| (
"Leader",
"dimension_style"
| "arrow_enabled"
| "path_type"
| "creation_type"
| "hookline_direction"
| "hookline_enabled"
| "text_height"
| "text_width"
| "vertices"
| "override_color"
| "annotation_handle"
| "normal"
| "horizontal_direction"
| "block_offset"
| "annotation_offset"
)
| ("MultiLeader", _)
| ("Table", _)
| ("PolygonMesh", _)
| ("PolyfaceMesh", _)
| ("Mesh", _)
| ("Helix", _)
| ("RasterImage", _)
| ("Wipeout", _)
| ("SectionSymbol", _)
| ("Surface", "u_isolines" | "v_isolines")
| ("Ole2Frame", _)
| ("Underlay", _)
| ("Viewport", _)
| ("ViewBorder", _)
| ("Light", _)
| (
"MLine",
"flags"
| "justification"
| "normal"
| "scale_factor"
| "style_name"
| "vertices"
)
) {
"transaction_geometry"
} else {
"type_conversion_only"
};
properties.push(PropertyCoverage {
name,
source_path: field.name.clone(),
type_id: if exposed.is_some_and(|name| name == "closed") {
"bool".into()
} else {
field.type_id.as_str().to_owned()
},
optional: field.optional,
is_sequence: field.is_sequence,
snapshot_readable: true,
validation: validation.into(),
model_access: access,
});
}
for synthetic in policy.synthetic.get(kind).into_iter().flatten() {
let writable = synthetic.access == "read_write";
properties.push(PropertyCoverage {
name: synthetic.name.clone(),
source_path: format!("<subtype>.{}", synthetic.name),
type_id: synthetic.type_id.clone(),
optional: false,
is_sequence: false,
snapshot_readable: true,
validation: if writable { "transaction_geometry" } else { "none" }.into(),
model_access: if writable { ModelAccess::ReadWrite } else { ModelAccess::ReadOnly },
});
}
assert!(
unresolved.is_empty(),
"{kind} has coverage keys absent from registry: {unresolved:?}"
);
entity_kinds.push(EntityKindCoverage {
kind: kind.clone(),
scope,
properties,
});
}
let catalog = EntityCoverageCatalog {
schema_version: 1,
entity_kinds,
};
fs::write(
out_dir.join("entity_coverage.json"),
serde_json::to_string_pretty(&catalog).unwrap(),
)
.expect("write entity coverage");
}
fn trace<T>(tracer: &mut Tracer, samples: &Samples)
@@ -908,7 +1283,7 @@ fn generate_version_info(out_dir: &Path) {
"acadrust_version": acadrust.version.to_string(),
"acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()),
"rustc_version": rustc_version,
"api_version": 5,
"api_version": 7,
"api_version_min_supported": 2,
"build_timestamp": build_timestamp,
});
@@ -0,0 +1,866 @@
{
"internal_kinds": [
"Block",
"BlockEnd",
"Seqend"
],
"opaque_kinds": [
"Extended",
"Unknown"
],
"editable": {
"Point": [
"layer",
"location",
"thickness",
"normal",
"x_axis_angle"
],
"Line": [
"layer",
"start",
"end",
"thickness",
"normal"
],
"Circle": [
"layer",
"center",
"radius",
"thickness",
"normal"
],
"Arc": [
"layer",
"center",
"radius",
"start_angle",
"end_angle",
"thickness",
"normal"
],
"Ellipse": [
"layer",
"center",
"major_axis",
"minor_axis_ratio",
"start_parameter",
"end_parameter",
"normal"
],
"Polyline": [
"layer",
"closed",
"vertices"
],
"Polyline2D": [
"layer",
"closed",
"smooth_surface",
"start_width",
"end_width",
"thickness",
"elevation",
"normal",
"vertices"
],
"Polyline3D": [
"layer",
"flags",
"default_start_width",
"default_end_width",
"mesh_m_count",
"mesh_n_count",
"smooth_m_density",
"smooth_n_density",
"elevation",
"normal",
"vertices"
],
"LwPolyline": [
"layer",
"vertices",
"is_closed",
"plinegen",
"constant_width",
"elevation",
"thickness",
"normal"
],
"Spline": [
"layer",
"degree",
"flags",
"knots",
"control_points",
"weights",
"fit_points",
"normal",
"knot_tolerance",
"control_tolerance",
"fit_tolerance",
"begin_tangent",
"end_tangent",
"knot_parameterization",
"cv_frame_visible",
"dwg_flags1",
"dxf_flags"
],
"Text": [
"layer",
"text",
"insertion",
"alignment_point",
"height",
"rotation",
"width_factor",
"oblique_angle",
"style",
"horizontal_alignment",
"vertical_alignment",
"normal",
"thickness",
"generation_flags"
],
"MText": [
"layer",
"text",
"insertion",
"height",
"rectangle_width",
"rectangle_height",
"rotation",
"dwg_x_direction",
"style",
"attachment_point",
"drawing_direction",
"line_spacing_factor",
"line_spacing_style",
"normal",
"background_fill_flags",
"background_scale",
"background_transparency",
"is_annotative",
"column_data",
"extents_width",
"extents_height"
],
"Ray": [
"layer",
"base_point",
"direction"
],
"XLine": [
"layer",
"base_point",
"direction"
],
"Solid": [
"layer",
"first_corner",
"second_corner",
"third_corner",
"fourth_corner",
"normal",
"thickness",
"is_trace"
],
"Face3D": [
"layer",
"first_corner",
"second_corner",
"third_corner",
"fourth_corner",
"invisible_edges"
],
"Insert": [
"layer",
"block_name",
"insert_point",
"x_scale",
"y_scale",
"z_scale",
"rotation",
"normal",
"column_count",
"row_count",
"column_spacing",
"row_spacing"
],
"Tolerance": [
"layer",
"insertion_point",
"direction",
"normal",
"text",
"dimension_style_name",
"text_height",
"dimension_gap"
],
"Shape": [
"layer",
"insertion_point",
"size",
"shape_name",
"shape_number",
"rotation",
"relative_x_scale",
"oblique_angle",
"normal",
"thickness",
"style_name"
],
"AttributeDefinition": [
"layer",
"tag",
"prompt",
"default_value",
"insertion_point",
"alignment_point",
"height",
"rotation",
"width_factor",
"oblique_angle",
"text_style",
"text_generation_flags",
"horizontal_alignment",
"vertical_alignment",
"flags",
"field_length",
"normal",
"mtext_flag",
"is_multiline",
"line_count",
"lock_position"
],
"AttributeEntity": [
"layer",
"tag",
"value",
"insertion_point",
"alignment_point",
"height",
"rotation",
"width_factor",
"oblique_angle",
"text_style",
"text_generation_flags",
"horizontal_alignment",
"vertical_alignment",
"flags",
"field_length",
"normal",
"mtext_flag",
"is_multiline",
"line_count",
"lock_position"
],
"Hatch": [
"layer",
"elevation",
"normal",
"is_solid",
"pattern",
"pattern_angle",
"pattern_scale",
"pattern_type",
"is_double",
"style",
"is_associative",
"pixel_size",
"paths",
"seed_points"
],
"Leader": [
"layer",
"dimension_style",
"arrow_enabled",
"path_type",
"creation_type",
"hookline_direction",
"hookline_enabled",
"text_height",
"text_width",
"vertices",
"override_color",
"annotation_handle",
"normal",
"horizontal_direction",
"block_offset",
"annotation_offset"
],
"MLine": [
"layer",
"flags",
"justification",
"normal",
"scale_factor",
"style_name",
"vertices"
],
"MultiLeader": [
"layer",
"content_type",
"path_type",
"line_color",
"line_weight",
"enable_landing",
"enable_dogleg",
"dogleg_length",
"arrowhead_size",
"text_color",
"text_frame",
"text_left_attachment",
"text_right_attachment",
"text_top_attachment",
"text_bottom_attachment",
"text_attachment_direction",
"text_attachment_point",
"text_alignment",
"text_angle_type",
"text_direction_negative",
"scale_factor",
"enable_annotation_scale",
"extend_leader_to_text",
"block_content_color",
"block_connection_type",
"block_rotation",
"block_scale",
"context",
"style_handle",
"text_style_handle",
"arrowhead_handle",
"line_type_handle",
"block_content_handle"
],
"Table": [
"layer",
"insertion_point",
"horizontal_direction",
"normal",
"table_style_handle",
"rows",
"columns",
"merged_ranges",
"break_spacing",
"break_flow_direction",
"break_options"
],
"PolygonMesh": [
"layer",
"flags",
"m_vertex_count",
"n_vertex_count",
"m_smooth_density",
"n_smooth_density",
"smooth_type",
"elevation",
"normal",
"vertices"
],
"PolyfaceMesh": [
"layer",
"elevation",
"flags",
"normal",
"start_width",
"end_width",
"smooth_surface",
"thickness",
"vertices",
"faces"
],
"Mesh": [
"layer",
"blend_crease",
"subdivision_level",
"vertices",
"faces",
"edges",
"override_option"
],
"Helix": [
"layer",
"axis_base_point",
"start_point",
"axis_vector",
"radius",
"turns",
"turn_height",
"handedness",
"constraint"
],
"RasterImage": [
"layer",
"insertion_point",
"u_vector",
"v_vector",
"flags",
"clipping_enabled",
"brightness",
"contrast",
"fade",
"clip_boundary",
"file_path"
],
"Wipeout": [
"layer",
"insertion_point",
"u_vector",
"v_vector",
"size",
"flags",
"clipping_enabled",
"brightness",
"contrast",
"fade",
"clip_mode",
"clip_type",
"clip_boundary_vertices"
],
"Underlay": [
"layer",
"underlay_type",
"definition_handle",
"insertion_point",
"x_scale",
"y_scale",
"z_scale",
"rotation",
"normal",
"flags",
"contrast",
"fade",
"clip_boundary_vertices",
"clip_inverted"
],
"Viewport": [
"layer",
"center",
"width",
"height",
"status",
"view_center",
"view_direction",
"view_target",
"lens_length",
"front_clip_z",
"back_clip_z",
"view_height",
"twist_angle",
"frozen_layers",
"render_mode",
"circle_sides",
"ucs_icon_visible"
],
"ViewBorder": [
"layer",
"min",
"max",
"center",
"scale",
"rotation_angle",
"active_viewport",
"scale_handle"
],
"Light": [
"layer",
"name",
"light_type",
"position",
"target",
"status",
"light_color",
"plot_glyph",
"intensity",
"attenuation_type",
"use_attenuation_limits",
"attenuation_start_limit",
"attenuation_end_limit",
"hotspot_angle",
"falloff_angle",
"cast_shadows",
"shadow_type",
"shadow_map_size",
"shadow_map_softness",
"photometric_mode",
"photometric_data"
],
"Ole2Frame": [
"layer",
"upper_left_corner",
"lower_right_corner",
"lock_aspect",
"is_paper_space"
],
"Solid3D": [
"layer"
],
"Body": [
"layer"
],
"Region": [
"layer"
],
"Surface": [
"layer",
"u_isolines",
"v_isolines"
],
"SectionSymbol": [
"layer",
"symbol_scale",
"points",
"style_handle",
"view_rep_handle"
]
},
"readable": {
"Tolerance": [
"dimension_style_handle"
],
"Shape": [
"style_handle"
],
"MLine": [
"start_point",
"style_handle",
"style_element_count"
],
"Leader": [
"origin",
"dimension_gap",
"arrowhead_type",
"arrow_size",
"byblock_color"
],
"AttributeEntity": [
"attdef_handle"
],
"MultiLeader": [
"dwg_version",
"property_override_flags",
"text_align_in_ipe",
"text_height"
],
"Table": [
"block_name",
"block_record_handle",
"data_version",
"value_flags",
"override_flag",
"override_border_color",
"override_border_line_weight",
"override_border_visibility",
"legacy_style_override",
"name",
"description",
"field_handles",
"base_style",
"break_data",
"break_ranges"
],
"PolyfaceMesh": [
"seqend_handle"
],
"Mesh": [
"version"
],
"Helix": [
"spline",
"major_version",
"maintenance_version"
],
"RasterImage": [
"class_version",
"definition_handle",
"definition_reactor_handle",
"size"
],
"Wipeout": [
"class_version",
"definition_handle",
"definition_reactor_handle"
],
"Viewport": [
"id",
"snap_base",
"snap_spacing",
"grid_spacing",
"snap_angle",
"clip_boundary_handle",
"style_sheet",
"ucs_at_origin",
"ucs_per_viewport",
"ucs_origin",
"ucs_x_axis",
"ucs_y_axis",
"ucs_handle",
"base_ucs_handle",
"ucs_ortho_type",
"elevation",
"shade_plot_mode",
"grid_flags",
"grid_major",
"background_handle",
"shade_plot_handle",
"visual_style_handle",
"sun_handle",
"default_lighting",
"default_lighting_type",
"brightness",
"contrast",
"ambient_color",
"custom_scale"
],
"ViewBorder": [
"version"
],
"Light": [
"class_version"
],
"Ole2Frame": [
"version",
"source_application",
"ole_object_type",
"dwg_mode"
],
"Solid3D": [
"uid",
"point_of_reference",
"history_handle"
],
"Body": [
"uid",
"point_of_reference",
"history_handle"
],
"Region": [
"uid",
"point_of_reference",
"history_handle"
],
"Surface": [
"surface_kind",
"modeler_format_version",
"point_of_reference",
"history_handle"
],
"SectionSymbol": [
"view_symbol_version",
"raw_view_symbol_70",
"version",
"raw_point_count_90",
"raw_flags_90",
"raw_point_record_count",
"end_a",
"end_b",
"tick_a",
"tick_b",
"label"
]
},
"aliases": {
"Polyline": {
"closed": "flags"
},
"Polyline2D": {
"closed": "flags"
},
"Text": {
"text": "value",
"insertion": "insertion_point"
},
"MText": {
"text": "value",
"insertion": "insertion_point"
},
"Surface": {
"surface_kind": "kind"
}
},
"synthetic": {
"Dimension": [
{
"name": "subtype",
"type_id": "String",
"access": "read_only"
},
{
"name": "actual_measurement",
"type_id": "f64",
"access": "read_only"
},
{
"name": "text",
"type_id": "String",
"access": "read_write"
},
{
"name": "style_name",
"type_id": "String",
"access": "read_write"
},
{
"name": "normal",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "text_middle_point",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "attachment_point",
"type_id": "AttachmentPointType",
"access": "read_write"
},
{
"name": "text_rotation",
"type_id": "f64",
"access": "read_write"
},
{
"name": "horizontal_direction",
"type_id": "f64",
"access": "read_write"
},
{
"name": "flip_arrow1",
"type_id": "bool",
"access": "read_write"
},
{
"name": "flip_arrow2",
"type_id": "bool",
"access": "read_write"
},
{
"name": "text_user_positioned",
"type_id": "bool",
"access": "read_write"
},
{
"name": "definition_point",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "first_point",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "second_point",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "angle_vertex",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "dimension_arc",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "feature_location",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "leader_endpoint",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "first_extension_point",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "second_extension_point",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "center_point",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "first_leader_point",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "second_leader_point",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "chord_point",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "override_center",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "jog_point",
"type_id": "Vector3",
"access": "read_write"
},
{
"name": "rotation",
"type_id": "f64",
"access": "read_write"
},
{
"name": "ext_line_rotation",
"type_id": "f64",
"access": "read_write"
},
{
"name": "leader_length",
"type_id": "f64",
"access": "read_write"
},
{
"name": "arc_start_parameter",
"type_id": "f64",
"access": "read_write"
},
{
"name": "arc_end_parameter",
"type_id": "f64",
"access": "read_write"
},
{
"name": "jog_angle",
"type_id": "f64",
"access": "read_write"
},
{
"name": "is_ordinate_type_x",
"type_id": "bool",
"access": "read_write"
},
{
"name": "is_partial",
"type_id": "bool",
"access": "read_write"
},
{
"name": "has_leader",
"type_id": "bool",
"access": "read_write"
}
]
}
}
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(_)));
}