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

+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.