From 93afe91afc93eadcfd9302577aacf8867199c8b4 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Fri, 18 Sep 2026 12:13:43 +0200 Subject: [PATCH 01/76] Publish built-in document edits to V4 plugins --- src/app/automation.rs | 32 ++++++++++++++++++++++++++++++++ src/app/mod.rs | 7 +++++++ src/app/plugin_host.rs | 6 +++++- src/app/update/mod.rs | 19 +++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/app/automation.rs b/src/app/automation.rs index 8012361d7..9b12dcbb5 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -283,6 +283,8 @@ impl OpenCADStudio { // selection check has to run on this path too. #[cfg(not(target_arch = "wasm32"))] self.notify_plugins_selection_changed(); + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_document_changed(); res } @@ -737,6 +739,36 @@ impl OpenCADStudio { #[cfg(test)] mod tests { + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn built_in_edits_advance_plugin_document_fingerprint_once() { + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let before = app.last_plugin_document.expect("new drawing published"); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#); + let after = app.last_plugin_document.expect("line edit published"); + assert_eq!(after.0, app.tabs[app.active_tab].id); + assert_ne!(after, before); + assert_eq!(after.1, app.tabs[app.active_tab].scene.geometry_epoch); + app.automation_op(r#"{"op":"query","type":"Line"}"#); + assert_eq!(app.last_plugin_document, Some(after)); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn plugin_edit_publication_is_not_repeated_at_message_boundary() { + use acadrust::entities::Point; + use acadrust::EntityType; + + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let tab = app.active_tab; + let mut host = super::super::plugin_host::HostSession::new(&mut app, tab); + host.add_entity(EntityType::Point(Point::new())); + let published = app.last_plugin_document.expect("plugin write published"); + app.notify_plugins_document_changed(); + assert_eq!(app.last_plugin_document, Some(published)); + } use crate::app::OpenCADStudio; #[test] diff --git a/src/app/mod.rs b/src/app/mod.rs index 69e09fc03..b3d1aec92 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -802,6 +802,11 @@ pub(super) struct OpenCADStudio { /// `SelectionChangedV4` fires once per real change rather than per message. #[cfg(not(target_arch = "wasm32"))] last_plugin_selection: Option<(u64, u64)>, + /// `(tab id, geometry epoch)` last published to the V4 document view. + /// Built-in edits bypass HostSession, so this is checked at message + /// boundaries as well as after plugin-initiated writes. + #[cfg(not(target_arch = "wasm32"))] + last_plugin_document: Option<(u64, u64)>, /// External add-on packages found in the plugins folder, refreshed when the /// Plugin Manager opens. external_plugins: Vec, @@ -3750,6 +3755,8 @@ impl OpenCADStudio { disabled_plugins: rustc_hash::FxHashSet::default(), #[cfg(not(target_arch = "wasm32"))] last_plugin_selection: None, + #[cfg(not(target_arch = "wasm32"))] + last_plugin_document: None, external_plugins: Vec::new(), loaded_plugin_ids: rustc_hash::FxHashSet::default(), plugin_load_errors: rustc_hash::FxHashMap::default(), diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index d60992822..25033addc 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -92,7 +92,11 @@ impl<'a> HostSession<'a> { } } #[cfg(not(target_arch = "wasm32"))] - v4_support::publish_document_view_v4(self.tab_id(), doc); + { + v4_support::publish_document_view_v4(self.tab_id(), doc); + self.app.last_plugin_document = + Some((self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch)); + } } pub fn add_entity(&mut self, entity: EntityType) -> Handle { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 1f999afd0..e0f9a23a4 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -262,6 +262,23 @@ impl OpenCADStudio { crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles); } + /// Refresh the shared V4 document after built-in geometry edits. Plugin + /// writes publish through HostSession immediately; the fingerprint avoids + /// sending a duplicate notification at this message boundary. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn notify_plugins_document_changed(&mut self) { + if self.active_tab >= self.tabs.len() { + return; + } + let tab = &self.tabs[self.active_tab]; + let key = (tab.id, tab.scene.geometry_epoch); + if self.last_plugin_document == Some(key) { + return; + } + self.last_plugin_document = Some(key); + crate::plugin::v4_support::publish_document_view_v4(tab.id, &tab.scene.document); + } + pub fn update(&mut self, msg: Message) -> Task { self.control_observe_user_message(&msg); let perf_started = crate::perf::enabled().then(Instant::now); @@ -319,6 +336,8 @@ impl OpenCADStudio { // and plugin request draining). #[cfg(not(target_arch = "wasm32"))] self.notify_plugins_selection_changed(); + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_document_changed(); // OTRACK acquires tracking points only while a command or grip drag is // running; drop them once neither is active so the temporary tracking // points / vectors disappear when the command ends (issue #64). From ec2271fc9c67d4ee9e2f06bcad15c15ff946126f Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Fri, 18 Sep 2026 15:13:06 +0200 Subject: [PATCH 02/76] Add versioned host settings API for plugins --- crates/ocs_plugin_api/ARCHITECTURE.md | 6 +- crates/ocs_plugin_api/README.md | 11 +++- crates/ocs_plugin_api/build.rs | 2 +- crates/ocs_plugin_api/src/host.rs | 23 +++++++ crates/ocs_plugin_api/src/ipc/client.rs | 30 ++++++++- crates/ocs_plugin_api/src/ipc/protocol.rs | 8 ++- crates/ocs_plugin_api/src/ipc/server.rs | 6 ++ crates/ocs_plugin_api/src/ipc/v4/client.rs | 30 ++++++++- crates/ocs_plugin_api/src/manifest.rs | 7 +- src/app/automation.rs | 10 +++ src/app/commands/styleprops.rs | 5 +- src/app/layers.rs | 21 ++++++ src/app/plugin_host.rs | 76 +++++++++++++++++++++- 13 files changed, 218 insertions(+), 17 deletions(-) diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index 61bc16b05..c078173a6 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -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 fork currently + supports `CLAYER` and `SNAPANG`. The runtime enforces three gates: diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index e1d29878d..d360b4daf 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -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 diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 7a284bc60..d8ed9a7b0 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -908,7 +908,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": 6, "api_version_min_supported": 2, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 19130c596..4dad141b4 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -60,6 +60,13 @@ pub enum LogLevel { Error, } +/// 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)] @@ -552,6 +559,22 @@ pub trait HostApi { let _ = tab_id; None } + + /// Read a named host setting without invoking the command dispatcher. + /// Names are case-insensitive. Hosts may support only a subset. + fn system_variable(&self, _name: &str) -> Option { + 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 { + Err("system variable is not supported by this host".to_owned()) + } } /// Simplified, read-only entity kind exposed by [`DocumentReader`]. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 3a617fc02..b78d90e61 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -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, }; @@ -416,6 +416,34 @@ impl HostApi for PluginHostApi { } } } + + fn system_variable(&self, name: &str) -> Option { + 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 { + 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()), + } + } } /// Sentinel reader used when the shared-memory view could not be initialized. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index c20cad1b2..8dcc7c588 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -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}; @@ -138,6 +138,10 @@ pub enum PluginRequest { GetTabId, /// V5: ask the host for the filesystem path of the document in `tab_id`. DocumentPath { tab_id: u64 }, + /// 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 }, } /// Responses the host sends back for `PluginRequest`. @@ -164,6 +168,8 @@ pub enum PluginResponse { TabId(u64), /// V5: filesystem path of the document in the requested tab, if any. DocumentPath(Option), + SystemVariable(Option), + SystemVariableResult(Result), } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 055880ef0..ff1c9e510 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -77,5 +77,11 @@ pub fn handle_plugin_request( DocumentPath { tab_id } => PluginResponse::DocumentPath( host.document_path(tab_id).map(|path| path.into_os_string()), ), + GetSystemVariable { name } => { + PluginResponse::SystemVariable(host.system_variable(&name)) + } + SetSystemVariable { name, value } => { + PluginResponse::SystemVariableResult(host.set_system_variable(&name, value)) + } } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 03cbf20ae..c77202060 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -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, @@ -730,6 +730,34 @@ impl HostApi for V4PluginHostApi { } } } + + fn system_variable(&self, name: &str) -> Option { + 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 { + 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()), + } + } } #[cfg(test)] diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index b0e4b3dcd..5edc5671e 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -19,8 +19,9 @@ /// 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`). +pub const API_VERSION: u32 = 6; /// 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 +29,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"; diff --git a/src/app/automation.rs b/src/app/automation.rs index 9b12dcbb5..afdc0fc5e 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -739,6 +739,16 @@ impl OpenCADStudio { #[cfg(test)] mod tests { + #[test] + fn clayer_command_sets_layer_used_by_new_geometry() { + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"LAYER NEW Annotations"}"#); + app.automation_op(r#"{"op":"run","cmd":"CLAYER Annotations"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#); + let lines = app.automation_op(r#"{"op":"query","type":"Line"}"#); + assert_eq!(lines["entities"][0]["layer"], "Annotations"); + } #[cfg(not(target_arch = "wasm32"))] #[test] fn built_in_edits_advance_plugin_document_fingerprint_once() { diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 751d2e5d2..f8ea83953 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -2649,10 +2649,7 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("CLAYER = \"{cur}\"").as_ref()); } else { - if self.tabs[i].scene.document.layers.contains(name_arg) { - self.tabs[i].scene.document.header.current_layer_name = - name_arg.to_string(); - self.tabs[i].dirty = true; + if self.set_current_layer_name(i, name_arg).is_ok() { self.command_line .push_output(crate::tf!("CLAYER set to \"{name_arg}\"").as_ref()); } else { diff --git a/src/app/layers.rs b/src/app/layers.rs index 6be69c8f3..e72d704b4 100644 --- a/src/app/layers.rs +++ b/src/app/layers.rs @@ -2,6 +2,27 @@ use super::OpenCADStudio; use crate::ui; impl OpenCADStudio { + /// Apply the current layer to the document header and all per-tab/UI + /// creation state. Both built-in commands and plugin settings use this. + pub(super) fn set_current_layer_name(&mut self, tab: usize, layer: &str) -> Result<(), String> { + let handle = self.tabs[tab] + .scene + .document + .layers + .get(layer) + .map(|entry| entry.handle) + .ok_or_else(|| format!("layer {layer:?} does not exist"))?; + self.tabs[tab].scene.document.header.current_layer_name = layer.to_owned(); + self.tabs[tab].scene.document.header.current_layer_handle = handle; + self.tabs[tab].active_layer = layer.to_owned(); + self.tabs[tab].layers.current_layer = layer.to_owned(); + self.tabs[tab].dirty = true; + if tab == self.active_tab { + self.ribbon.active_layer = layer.to_owned(); + self.refresh_layer_panel(); + } + Ok(()) + } pub(super) fn load_layer_state_editor(&mut self, selected: Option) { let i = self.active_tab; if let Some(name) = selected { diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 25033addc..b572786ce 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -4,7 +4,7 @@ use std::any::Any; use acadrust::tables::AppId; use acadrust::xdata::ExtendedDataRecord; -use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi}; +use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi, HostSettingValue}; use ocs_plugin_api::shm::{DocumentSnapshotStore, DocumentViewData}; #[cfg(not(target_arch = "wasm32"))] @@ -250,6 +250,41 @@ impl<'a> HostSession<'a> { self.app.tabs[self.tab].dirty = true; } + pub fn system_variable(&self, name: &str) -> Option { + match name.to_ascii_uppercase().as_str() { + "CLAYER" => Some(HostSettingValue::Text( + self.document().header.current_layer_name.clone(), + )), + "SNAPANG" => Some(HostSettingValue::Number(self.app.snap_angle_deg as f64)), + _ => None, + } + } + + pub fn set_system_variable( + &mut self, + name: &str, + value: HostSettingValue, + ) -> Result { + match (name.to_ascii_uppercase().as_str(), value) { + ("CLAYER", HostSettingValue::Text(layer)) => { + self.app.set_current_layer_name(self.tab, &layer)?; + Ok(HostSettingValue::Text(layer)) + } + ("SNAPANG", HostSettingValue::Number(angle)) if (angle as f32).is_finite() => { + self.app.snap_angle_deg = (angle as f32).rem_euclid(360.0); + Ok(HostSettingValue::Number(self.app.snap_angle_deg as f64)) + } + ("SNAPANG", HostSettingValue::Number(_)) => { + Err("SNAPANG: finite number required".to_owned()) + } + ("CLAYER", _) | ("SNAPANG", _) => Err(format!( + "{}: wrong value type", + name.to_ascii_uppercase() + )), + _ => Err(format!("unsupported system variable {name:?}")), + } + } + pub fn push_info(&mut self, msg: &str) { self.app.command_line.push_info(msg); } @@ -356,6 +391,16 @@ impl HostApi for HostSession<'_> { fn document_path(&self, tab_id: u64) -> Option { self.document_path(tab_id) } + fn system_variable(&self, name: &str) -> Option { + self.system_variable(name) + } + fn set_system_variable( + &mut self, + name: &str, + value: HostSettingValue, + ) -> Result { + self.set_system_variable(name, value) + } #[cfg(not(target_arch = "wasm32"))] fn document_view_v4(&mut self, tab_id: u64) -> Option { self.document_view_v4(tab_id) @@ -514,6 +559,35 @@ mod tests { use acadrust::xdata::XDataValue; use ocs_plugin_api::host::DocumentReader; + #[test] + fn system_variables_change_without_command_reentry() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + assert_eq!(host.system_variable("clayer"), Some(HostSettingValue::Text("0".into()))); + assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(0.0))); + assert!(host.set_system_variable("CLAYER", HostSettingValue::Text("Missing".into())).is_err()); + assert_eq!(host.system_variable("CLAYER"), Some(HostSettingValue::Text("0".into()))); + + let mut point = Point::new(); + point.common.layer = "Annotations".into(); + host.add_entity(EntityType::Point(point)); + assert_eq!(host.set_system_variable("clayer", HostSettingValue::Text("Annotations".into())), + Ok(HostSettingValue::Text("Annotations".into()))); + assert_eq!(host.system_variable("CLAYER"), Some(HostSettingValue::Text("Annotations".into()))); + assert_eq!(host.app.tabs[0].active_layer, "Annotations"); + assert_eq!(host.app.tabs[0].layers.current_layer, "Annotations"); + assert_eq!(host.app.ribbon.active_layer, "Annotations"); + assert!(host.app.tabs[0].dirty); + + assert_eq!(host.set_system_variable("snapang", HostSettingValue::Number(450.0)), + Ok(HostSettingValue::Number(90.0))); + assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(90.0))); + assert!(host.set_system_variable("SNAPANG", HostSettingValue::Number(f64::INFINITY)).is_err()); + assert!(host.set_system_variable("SNAPANG", HostSettingValue::Number(f64::MAX)).is_err()); + assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(90.0))); + } + #[test] fn xdata_record_round_trips_and_registers_appid() { let mut app = OpenCADStudio::new_for_test(); From 11d561499198b406b44a69a159c615090b2ac82a Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 01:13:24 +0200 Subject: [PATCH 03/76] Add versioned scripting host model and entity coverage --- crates/ocs_plugin_api/Cargo.toml | 3 +- crates/ocs_plugin_api/build.rs | 113 +++++- .../entity_coverage_policy.json | 28 ++ crates/ocs_plugin_api/src/entity_coverage.rs | 342 ++++++++++++++++++ .../src/entity_coverage_types.rs | 52 +++ crates/ocs_plugin_api/src/host.rs | 45 +++ crates/ocs_plugin_api/src/ipc/client.rs | 34 ++ crates/ocs_plugin_api/src/ipc/protocol.rs | 10 + crates/ocs_plugin_api/src/ipc/server.rs | 5 + crates/ocs_plugin_api/src/ipc/v4/client.rs | 34 ++ crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 31 ++ crates/ocs_plugin_api/src/lib.rs | 4 + crates/ocs_plugin_api/src/manifest.rs | 5 +- crates/ocs_plugin_api/src/process.rs | 10 + crates/ocs_plugin_api/src/process/v4.rs | 1 + crates/ocs_plugin_api/src/runner.rs | 8 + docs/plugin-host-model.md | 88 +++++ src/app/plugin_host.rs | 167 +++++++++ src/app/update/mod.rs | 16 + src/plugin/v4_support.rs | 10 + src/scene/entity.rs | 3 + src/scene/selection.rs | 10 + 22 files changed, 1015 insertions(+), 4 deletions(-) create mode 100644 crates/ocs_plugin_api/entity_coverage_policy.json create mode 100644 crates/ocs_plugin_api/src/entity_coverage.rs create mode 100644 crates/ocs_plugin_api/src/entity_coverage_types.rs create mode 100644 docs/plugin-host-model.md diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index fb55f5254..d0d28766c 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -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" diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index d8ed9a7b0..765360e34 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -14,6 +14,18 @@ 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 EntityCoveragePolicy { + internal_kinds: Vec, + opaque_kinds: Vec, + editable: BTreeMap>, + aliases: BTreeMap>, +} fn main() { let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); @@ -23,6 +35,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 +500,103 @@ fn generate_type_registry(out_dir: &Path) { ); let json = serde_json::to_string_pretty(®istry).unwrap(); fs::write(out_dir.join("type_registry.json"), json).unwrap(); + generate_entity_coverage(out_dir, ®istry); +} + +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 = ®istry.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()) { + 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 = ®istry.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 mut unresolved: HashSet = editable.iter().cloned().collect(); + assert_eq!(unresolved.len(), editable.len(), "duplicate editable property in {kind}"); + let aliases = policy.aliases.get(kind); + let mut properties = vec![PropertyCoverage { + name: "kind".into(), source_path: "".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), + "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 = ®istry.types[&shape_type]; + for field in &shape.fields { + if field.name == "common" { continue; } + let exposed = editable.iter().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(), ModelAccess::ReadWrite) + } + 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")) { + "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, + }); + } + 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(tracer: &mut Tracer, samples: &Samples) @@ -908,7 +1019,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": 6, + "api_version": 7, "api_version_min_supported": 2, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json new file mode 100644 index 000000000..513af55a4 --- /dev/null +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -0,0 +1,28 @@ +{ + "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"] + }, + "aliases": { + "Polyline": {"closed": "flags"}, + "Polyline2D": {"closed": "flags"}, + "Text": {"text": "value", "insertion": "insertion_point"}, + "MText": {"text": "value", "insertion": "insertion_point"} + } +} diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs new file mode 100644 index 000000000..5634c542f --- /dev/null +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -0,0 +1,342 @@ +//! Host-owned coverage generated from the traced entity registry and an +//! explicit policy. `unmapped` fields remain accessible through the typed +//! `CadDocument` snapshot but are not promised by the Python document model. + +pub use crate::entity_coverage_types::*; + +pub const EMBEDDED_ENTITY_COVERAGE_JSON: &str = + include_str!(concat!(env!("OUT_DIR"), "/entity_coverage.json")); + +pub fn get_embedded_entity_coverage_json() -> &'static str { + EMBEDDED_ENTITY_COVERAGE_JSON +} + +/// Read-only JSON serialization of one typed entity. The outer object +/// has one key: the `EntityType` variant name used by the coverage catalog. +/// JSON does not preserve non-finite floating-point values, so this is an +/// inspection format and must remain separate from the typed entity write path. +#[cfg(feature = "host")] +pub fn entity_snapshot(entity: &crate::host::EntityType) -> Result { + serde_json::to_value(entity) +} + +#[cfg(feature = "host")] +fn finite_vector(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> { + if value.x.is_finite() && value.y.is_finite() && value.z.is_finite() { Ok(()) } + else { Err(format!("{name} must contain finite coordinates")) } +} + +#[cfg(feature = "host")] +fn unit_direction(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> { + finite_vector(name, value)?; + let length = value.x.hypot(value.y).hypot(value.z); + if (length - 1.0).abs() <= 1e-6 { Ok(()) } + else { Err(format!("{name} must be a unit vector")) } +} + +#[cfg(feature = "host")] +fn solid_normal(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> { + finite_vector(name, value)?; + if value.x.hypot(value.y).hypot(value.z) > 0.0 { Ok(()) } + else { Err(format!("{name} must be nonzero")) } +} + +/// Validate newly created geometry for the kinds whose mapped fields have +/// host checks. Called by the Python add path before an entity enters the document. +#[cfg(feature = "host")] +pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<(), String> { + use crate::host::EntityType; + if entity.common().layer.trim().is_empty() { return Err("layer name is empty".into()); } + match entity { + EntityType::Ray(value) => { + finite_vector("Ray.base_point", &value.base_point)?; + unit_direction("Ray.direction", &value.direction)?; + } + EntityType::XLine(value) => { + finite_vector("XLine.base_point", &value.base_point)?; + unit_direction("XLine.direction", &value.direction)?; + } + EntityType::Solid(value) => { + for (name, corner) in [ + ("first_corner", &value.first_corner), ("second_corner", &value.second_corner), + ("third_corner", &value.third_corner), ("fourth_corner", &value.fourth_corner), + ] { finite_vector(&format!("Solid.{name}"), corner)?; } + solid_normal("Solid.normal", &value.normal)?; + if !value.thickness.is_finite() { return Err("Solid.thickness must be finite".into()); } + } + EntityType::Face3D(value) => { + for (name, corner) in [ + ("first_corner", &value.first_corner), ("second_corner", &value.second_corner), + ("third_corner", &value.third_corner), ("fourth_corner", &value.fourth_corner), + ] { finite_vector(&format!("Face3D.{name}"), corner)?; } + if value.invisible_edges.bits() & !0x0f != 0 { + return Err("Face3D.invisible_edges has unknown bits".into()); + } + } + _ => {} + } + Ok(()) +} + +/// Validate geometry changed by a typed replacement before an undoable +/// transaction commits. Legacy invalid values in untouched fields remain +/// untouched, so editing a layer does not unexpectedly reject an old drawing. +#[cfg(feature = "host")] +pub fn validate_entity_mutation( + before: &crate::host::EntityType, + after: &crate::host::EntityType, +) -> Result<(), String> { + use crate::host::EntityType; + if before.common().layer != after.common().layer { + if after.common().layer.trim().is_empty() { return Err("layer name is empty".into()); } + if matches!(after, EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::Seqend(_) + | EntityType::Extended(_) | EntityType::Unknown(_)) { + return Err("entity kind does not support canvas layer edits".into()); + } + } + let finite3 = |name: &str, x: f64, y: f64, z: f64| { + if x.is_finite() && y.is_finite() && z.is_finite() { Ok(()) } + else { Err(format!("{name} must contain finite coordinates")) } + }; + let same3 = |a: &crate::host::acadrust::types::Vector3, + b: &crate::host::acadrust::types::Vector3| { + a.x.to_bits() == b.x.to_bits() && a.y.to_bits() == b.y.to_bits() && a.z.to_bits() == b.z.to_bits() + }; + let radius = |name: &str, value: f64| { + if value.is_finite() && value > 0.0 { Ok(()) } + else { Err(format!("{name} must be finite and greater than zero")) } + }; + let changed3 = |name: &str, old: &crate::host::acadrust::types::Vector3, + new: &crate::host::acadrust::types::Vector3| { + if !same3(old, new) { finite_vector(name, new) } else { Ok(()) } + }; + match (before, after) { + (EntityType::Point(old), EntityType::Point(new)) => { + if !same3(&old.location, &new.location) { + finite3("Point.location", new.location.x, new.location.y, new.location.z)?; + } + } + (EntityType::Line(old), EntityType::Line(new)) => { + if !same3(&old.start, &new.start) { + finite3("Line.start", new.start.x, new.start.y, new.start.z)?; + } + if !same3(&old.end, &new.end) { + finite3("Line.end", new.end.x, new.end.y, new.end.z)?; + } + } + (EntityType::Circle(old), EntityType::Circle(new)) => { + if !same3(&old.center, &new.center) { + finite3("Circle.center", new.center.x, new.center.y, new.center.z)?; + } + if old.radius.to_bits() != new.radius.to_bits() { radius("Circle.radius", new.radius)?; } + } + (EntityType::Arc(old), EntityType::Arc(new)) => { + if !same3(&old.center, &new.center) { + finite3("Arc.center", new.center.x, new.center.y, new.center.z)?; + } + if old.radius.to_bits() != new.radius.to_bits() { radius("Arc.radius", new.radius)?; } + } + (EntityType::Ray(old), EntityType::Ray(new)) => { + changed3("Ray.base_point", &old.base_point, &new.base_point)?; + if !same3(&old.direction, &new.direction) { unit_direction("Ray.direction", &new.direction)?; } + } + (EntityType::XLine(old), EntityType::XLine(new)) => { + changed3("XLine.base_point", &old.base_point, &new.base_point)?; + if !same3(&old.direction, &new.direction) { unit_direction("XLine.direction", &new.direction)?; } + } + (EntityType::Solid(old), EntityType::Solid(new)) => { + for (name, old_corner, new_corner) in [ + ("first_corner", &old.first_corner, &new.first_corner), + ("second_corner", &old.second_corner, &new.second_corner), + ("third_corner", &old.third_corner, &new.third_corner), + ("fourth_corner", &old.fourth_corner, &new.fourth_corner), + ] { changed3(&format!("Solid.{name}"), old_corner, new_corner)?; } + if !same3(&old.normal, &new.normal) { solid_normal("Solid.normal", &new.normal)?; } + if old.thickness.to_bits() != new.thickness.to_bits() && !new.thickness.is_finite() { + return Err("Solid.thickness must be finite".into()); + } + } + (EntityType::Face3D(old), EntityType::Face3D(new)) => { + for (name, old_corner, new_corner) in [ + ("first_corner", &old.first_corner, &new.first_corner), + ("second_corner", &old.second_corner, &new.second_corner), + ("third_corner", &old.third_corner, &new.third_corner), + ("fourth_corner", &old.fourth_corner, &new.fourth_corner), + ] { changed3(&format!("Face3D.{name}"), old_corner, new_corner)?; } + if old.invisible_edges != new.invisible_edges && new.invisible_edges.bits() & !0x0f != 0 { + return Err("Face3D.invisible_edges has unknown bits".into()); + } + } + _ => {} + } + Ok(()) +} + +/// Clone a canvas entity with a new layer, preserving its other snapshot +/// fields. Internal records and opaque fallbacks are excluded. +#[cfg(feature = "host")] +pub fn patch_canvas_layer(entity: &crate::host::EntityType, layer: &str) -> Result { + use crate::host::EntityType; + if layer.trim().is_empty() { return Err("layer name is empty".into()); } + if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::Seqend(_) + | EntityType::Extended(_) | EntityType::Unknown(_)) { + return Err("entity kind does not support canvas layer edits".into()); + } + let mut changed = entity.clone(); + changed.common_mut().layer = layer.to_owned(); + Ok(changed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{get_embedded_type_registry_json, TypeId, TypeRegistry}; + + #[test] + fn every_entity_variant_has_exactly_one_classification() { + let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + let registry: TypeRegistry = serde_json::from_str(get_embedded_type_registry_json()).unwrap(); + let variants = ®istry.types[&TypeId::new("EntityType")].variants; + assert_eq!(catalog.entity_kinds.len(), variants.len()); + let mut names = std::collections::HashSet::new(); + for entry in &catalog.entity_kinds { + assert!(names.insert(&entry.kind), "duplicate kind {}", entry.kind); + assert!(variants.iter().any(|v| v.name == entry.kind)); + assert!(entry.properties.iter().any(|p| p.name == "handle" && p.model_access == ModelAccess::ReadOnly)); + assert!(entry.properties.iter().any(|p| p.name == "kind" && p.model_access == ModelAccess::ReadOnly)); + let mut source_paths = std::collections::HashSet::new(); + for property in &entry.properties { + assert!(source_paths.insert(&property.source_path), "duplicate source path in {}: {}", entry.kind, property.source_path); + assert!(property.snapshot_readable, "unreadable traced property in {}: {}", entry.kind, property.name); + if property.model_access == ModelAccess::ReadWrite { + assert!(matches!(property.validation.as_str(), "type_conversion_only" | "transaction_geometry" | "transaction_nonempty")); + } + } + } + } + + #[test] + fn unsupported_kinds_have_no_editable_properties() { + let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + for entry in &catalog.entity_kinds { + if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(), + "Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" | + "Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" | + "Ray" | "XLine" | "Solid" | "Face3D") { + assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite + || (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind); + } + } + } + + #[test] + fn catalog_names_the_fields_with_transaction_geometry_checks() { + let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + let checked: std::collections::HashSet<_> = catalog.entity_kinds.iter() + .flat_map(|entry| entry.properties.iter() + .filter(|property| property.validation == "transaction_geometry") + .map(|property| format!("{}.{}", entry.kind, property.name))) + .collect(); + assert_eq!(checked, std::collections::HashSet::from([ + "Point.location".to_owned(), "Line.start".to_owned(), "Line.end".to_owned(), + "Circle.center".to_owned(), "Circle.radius".to_owned(), + "Arc.center".to_owned(), "Arc.radius".to_owned(), + "Ray.base_point".to_owned(), "Ray.direction".to_owned(), + "XLine.base_point".to_owned(), "XLine.direction".to_owned(), + "Solid.first_corner".to_owned(), "Solid.second_corner".to_owned(), + "Solid.third_corner".to_owned(), "Solid.fourth_corner".to_owned(), + "Solid.normal".to_owned(), "Solid.thickness".to_owned(), + "Face3D.first_corner".to_owned(), "Face3D.second_corner".to_owned(), + "Face3D.third_corner".to_owned(), "Face3D.fourth_corner".to_owned(), + "Face3D.invisible_edges".to_owned(), + ])); + } + + #[test] + fn layer_write_coverage_matches_canvas_scope() { + let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + assert_eq!(catalog.entity_kinds.iter().filter(|entry| entry.scope == EntityScope::Canvas).count(), 43); + for entry in &catalog.entity_kinds { + let layer = entry.properties.iter().find(|property| property.name == "layer").unwrap(); + assert_eq!(layer.model_access == ModelAccess::ReadWrite, entry.scope == EntityScope::Canvas); + if entry.scope == EntityScope::Canvas { + assert_eq!(layer.validation, "transaction_nonempty"); + } + } + } + + #[cfg(feature = "host")] + #[test] + fn unsupported_canvas_kind_has_typed_snapshot_fields() { + use crate::host::acadrust; + let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default()); + let snapshot = entity_snapshot(&hatch).unwrap(); + let fields = snapshot.get("Hatch").and_then(|value| value.as_object()).unwrap(); + assert!(fields.contains_key("common")); + assert!(fields.len() > 1); + } + + #[cfg(feature = "host")] + #[test] + fn changed_geometry_is_validated_without_rejecting_untouched_legacy_values() { + use crate::host::acadrust; + let mut before = acadrust::entities::Circle::default(); + before.radius = f64::NAN; + let mut layer_only = before.clone(); + layer_only.common.layer = "OTHER".into(); + assert!(validate_entity_mutation( + &acadrust::EntityType::Circle(before.clone()), + &acadrust::EntityType::Circle(layer_only), + ).is_ok()); + let mut bad_radius = before.clone(); + bad_radius.radius = -1.0; + assert!(validate_entity_mutation( + &acadrust::EntityType::Circle(before), + &acadrust::EntityType::Circle(bad_radius), + ).unwrap_err().contains("Circle.radius")); + } + + #[cfg(feature = "host")] + #[test] + fn new_phase_seven_shapes_reject_invalid_geometry_and_keep_legacy_fields() { + use crate::host::acadrust::{self, types::Vector3}; + let mut ray = acadrust::entities::Ray::default(); + ray.direction = Vector3::new(2.0, 0.0, 0.0); + assert!(validate_new_canvas_entity(&acadrust::EntityType::Ray(ray.clone())).unwrap_err().contains("Ray.direction")); + let before = acadrust::EntityType::Ray(ray.clone()); + ray.common.layer = "CONSTRUCTION".into(); + assert!(validate_entity_mutation(&before, &acadrust::EntityType::Ray(ray.clone())).is_ok()); + ray.direction = Vector3::new(0.0, 1.0, 0.0); + assert!(validate_entity_mutation(&before, &acadrust::EntityType::Ray(ray)).is_ok()); + + let mut xline = acadrust::entities::XLine::default(); + xline.base_point.x = f64::NAN; + assert!(validate_new_canvas_entity(&acadrust::EntityType::XLine(xline)).unwrap_err().contains("XLine.base_point")); + + let solid = acadrust::entities::Solid::new(Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, Vector3::ZERO); + let mut changed = solid.clone(); + changed.thickness = f64::INFINITY; + assert!(validate_entity_mutation(&acadrust::EntityType::Solid(solid), &acadrust::EntityType::Solid(changed)) + .unwrap_err().contains("Solid.thickness")); + + let face = acadrust::entities::Face3D::new(Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, Vector3::ZERO); + let mut changed = face.clone(); + changed.invisible_edges = acadrust::entities::InvisibleEdgeFlags::from_bits(0x10); + assert!(validate_entity_mutation(&acadrust::EntityType::Face3D(face), &acadrust::EntityType::Face3D(changed)) + .unwrap_err().contains("Face3D.invisible_edges")); + } + + #[cfg(feature = "host")] + #[test] + fn canvas_layer_patch_preserves_unmapped_hatch_fields() { + use crate::host::acadrust; + let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default()); + let patched = patch_canvas_layer(&hatch, "HATCHES").unwrap(); + assert_eq!(patched.common().layer, "HATCHES"); + assert_eq!(hatch.common().layer, "0"); + let mut expected = hatch.clone(); + expected.common_mut().layer = "HATCHES".into(); + assert_eq!(patched, expected); + assert!(patch_canvas_layer(&hatch, " ").is_err()); + } +} diff --git a/crates/ocs_plugin_api/src/entity_coverage_types.rs b/crates/ocs_plugin_api/src/entity_coverage_types.rs new file mode 100644 index 000000000..9c056c2e2 --- /dev/null +++ b/crates/ocs_plugin_api/src/entity_coverage_types.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EntityCoverageCatalog { + pub schema_version: u32, + pub entity_kinds: Vec, +} diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 4dad141b4..b9a716dbd 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -83,6 +83,10 @@ pub enum HostNotification { DocumentTabClosed { tab_id: u64 }, /// V4 selection changed for a specific tab. Discriminant 7. SelectionChangedV4 { tab_id: u64, handles: Vec }, + /// V7 active interactive-command state for a tab. Discriminant 8. + CommandStateChanged { tab_id: u64, command: Option }, + /// 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. @@ -133,6 +137,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) @@ -170,6 +188,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)), } } @@ -575,6 +599,27 @@ pub trait HostApi { ) -> Result { 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, + ) -> 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 { 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()) + } } /// Simplified, read-only entity kind exposed by [`DocumentReader`]. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index b78d90e61..744232162 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -444,6 +444,40 @@ impl HostApi for PluginHostApi { Err(error) => Err(error.to_string()), } } + + fn update_entities_transaction( + &mut self, + label: &str, + entities: Vec, + ) -> 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 { + match self.client.request(PluginRequest::GetSelection) { + Ok(PluginResponse::Selection(handles)) => handles, + _ => Vec::new(), + } + } + + 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()), + } + } } /// Sentinel reader used when the shared-memory view could not be initialized. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 8dcc7c588..d6bd60a59 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -76,6 +76,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`. @@ -142,6 +144,11 @@ pub enum PluginRequest { 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 }, + /// V7: synchronous selection read/write for the dispatch tab. + GetSelection, + SetSelection { handles: Vec }, } /// Responses the host sends back for `PluginRequest`. @@ -170,6 +177,9 @@ pub enum PluginResponse { DocumentPath(Option), SystemVariable(Option), SystemVariableResult(Result), + EntityTransactionResult(Result<(), String>), + Selection(Vec), + SelectionResult(Result<(), String>), } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index ff1c9e510..ffa64e6e6 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -83,5 +83,10 @@ pub fn handle_plugin_request( 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)), } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index c77202060..1de1631ae 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -758,6 +758,40 @@ impl HostApi for V4PluginHostApi { Err(error) => Err(error.to_string()), } } + + fn update_entities_transaction( + &mut self, + label: &str, + entities: Vec, + ) -> 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 { + match self.request(PluginRequest::GetSelection) { + Ok(PluginResponse::Selection(handles)) => handles, + _ => Vec::new(), + } + } + + 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()), + } + } } #[cfg(test)] diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 34e97c2e9..4b6ddd583 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -86,6 +86,37 @@ mod tests { } } + #[test] + fn command_state_notification_roundtrips() { + let notification = HostNotification::CommandStateChanged { + tab_id: 42, + command: Some("LINE".to_owned()), + }; + let bytes = bincode::serialize(¬ification).unwrap(); + let decoded: HostNotification = bincode::deserialize(&bytes).unwrap(); + assert_eq!(decoded, notification); + let stopped = HostNotification::CommandStateChanged { tab_id: 42, command: None }; + let bytes = bincode::serialize(&stopped).unwrap(); + let decoded: HostNotification = bincode::deserialize(&bytes).unwrap(); + assert_eq!(decoded, stopped); + let changed = HostNotification::DrawingChanged { tab_id: 42, epoch: 7 }; + let bytes = bincode::serialize(&changed).unwrap(); + let decoded: HostNotification = bincode::deserialize(&bytes).unwrap(); + assert_eq!(decoded, changed); + } + + #[test] + fn 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::(&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::(&bytes).unwrap(), + PluginResponse::Selection(handles) if handles == vec![acadrust::Handle::new(9)])); + } + #[test] fn plugin_notification_roundtrip() { let frame = PluginToHostV4::Notification(NotificationEnvelope { diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index 9b4309220..c55cc0342 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -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)] diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 5edc5671e..8bf2ee892 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -20,8 +20,9 @@ /// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5 /// adds the `BuiltinPlugin::on_load` lifecycle callback and /// `HostApi::document_path`. v6 appends generic host system-variable access -/// (`HostApi::system_variable` / `set_system_variable`). -pub const API_VERSION: u32 = 6; +/// (`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 diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 7535af350..b77ca97ff 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -237,6 +237,7 @@ fn request_kind(req: &HostRequest) -> &'static str { HostRequest::GetPrompt { .. } => "GetPrompt", HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick", HostRequest::ExecuteCode { .. } => "ExecuteCode", + HostRequest::DropInteractive { .. } => "DropInteractive", HostRequest::Shutdown => "Shutdown", } } @@ -599,6 +600,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 { if let Some(v4) = &self.v4 { diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index 05e7dd490..fbf5edf3b 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -71,6 +71,7 @@ fn request_kind(req: &HostRequest) -> &'static str { HostRequest::GetPrompt { .. } => "GetPrompt", HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick", HostRequest::ExecuteCode { .. } => "ExecuteCode", + HostRequest::DropInteractive { .. } => "DropInteractive", HostRequest::Shutdown => "Shutdown", } } diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index b1d89ab0c..0a9ff9d95 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -191,6 +191,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(); @@ -290,6 +294,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(); diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md new file mode 100644 index 000000000..1eea48b33 --- /dev/null +++ b/docs/plugin-host-model.md @@ -0,0 +1,88 @@ +# Scripting host model (API v7) + +The host owns the drawing. Plugins can read the full `CadDocument` snapshot and +commit cloned `EntityType` values through IPC. `update_entities_transaction` +validates every replacement before changing the drawing, records one undo +entry, refreshes scene caches and the shared document view, and marks the +document dirty. The request preserves handles, owners, and entity kinds. A +missing or duplicate handle, a kind/owner change, or a locked source layer +rejects the whole batch. An empty batch does nothing. + +This is a full-entity replacement API, not a promise that every CAD property +is editable through Python. A client should clone the current value and patch +only fields it understands; unknown fields remain intact. The host can read +all `acadrust::EntityType` variants through `document()`. Its generic write +path accepts existing variants subject to the validation above. Entity +creation and deletion still use `add_entity` and `remove_entity` and are not +part of this transaction request. + +The host embeds a versioned coverage catalog generated from its traced +`EntityType` registry and `crates/ocs_plugin_api/entity_coverage_policy.json`. +It classifies all 48 variants: 43 canvas kinds, three internal records +(`Block`, `BlockEnd`, `Seqend`), and two opaque fallbacks (`Extended`, +`Unknown`). Each property records its source field, type, optional/sequence +shape, snapshot readability, Python access (`read_write`, `read_only`, or +`unmapped`), and validation status. `unmapped` means the typed snapshot carries +the field but the Python document model has no getter or setter for it. +The v7 transaction path checks changed Point locations, Line endpoints, +Circle/Arc centers, Ray/XLine base points, and Solid/Face3D corners for finite +coordinates. Circle/Arc radii must be finite and positive; Ray/XLine directions +must be unit vectors; Solid normals must be nonzero, its thickness finite, +and Face3D invisible-edge flags limited to four known bits. Those fields report `transaction_geometry` in the +catalog. All 43 canvas kinds also support nonempty `layer` changes through +the same undoable transaction; internal and opaque variants remain read-only +through the document model. The adapter accepts only `layer` patches for +canvas kinds outside its 16 geometry converters. Other writable properties +still report `type_conversion_only`; broader CAD range and cross-property +checks remain outstanding. +The host's `entity_snapshot` helper serializes any typed entity as a detached +JSON value keyed by its catalog variant name. This read path carries every +serializable field, including nested and unmapped values, without granting a +write path for them. The Python adapter exposes its variant payload through +`entity.snapshot`; changing that dictionary does not change the drawing. +It is an inspection view: JSON converts non-finite floating-point values to +null and cannot be used as a lossless replacement entity. + +The separate PandoraBox adapter currently maps these entity kinds to Python +dictionaries: Point, Line, Circle, Arc, Ellipse, Polyline, Polyline2D, +Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, and Face3D. The authoritative per-field mapping +is `plugins/opencad-python/entity_manifest.json` in PandoraBox. The generated +mapping excludes fields it cannot represent, including common color, line +weight, transparency, Polyline3D smooth type, and MText background color. +Absent geometry fields are not editable through the Python document model. +A feature test compares the generated Python keys with the +host catalog. The adapter's `experimental-host-model` feature requires API +v7; its portable default remains pinned to H7's v5 contract. + +API v7 also exposes a synchronous, ordered selection query and an atomic +selection replacement scoped to the session tab. All handles must exist before +replacement begins, duplicate handles are rejected, and the scripted +replacement keeps exactly the requested handles and order. UI picks continue +to group linked leaders and annotations. The V4 notification channel still broadcasts best-effort +document and selection changes. A new `DrawingChanged` notification carries +the scene epoch even when no shared document view is open. A new `CommandStateChanged` notification +reports transitions of the active interactive command at app message +boundaries, with the tab id and command name (or `None` when it ends). A +noninteractive command that starts and finishes within one message does not +produce an active-state transition. + +The host's existing `InteractiveCommand` machinery collects either a point or +an entity pick. PandoraBox's v7 adapter now exposes a token-based request and +poll API because `PY_RUN` uses a fresh Python interpreter per invocation; a +script cannot suspend in place while the user clicks. Enter and command +cancellation report a cancelled result. The host releases the runner's pick +command when it ends, including Escape cancellation. Drawing, selection, and command +notifications are retained in a bounded 256-event queue per tab between runs +and can be drained with `doc.poll_events()`. If a tab exceeds that bound, the +next poll begins with `{"type": "overflow", "dropped": n}`; scripts should +refresh drawing and selection state when they receive it. Closing a tab clears +its pending events, and a script can poll only its active tab. +Input tokens are bound to the drawing tab that requested the pick. Polling a +token from another tab returns no result and does not consume it. + +Python `doc.entities[handle]` returns a descriptor for every entity. For kinds +outside the 16-kind generated schema it contains only handle, kind, and layer; +`layer` is editable on canvas kinds. `doc.coverage()` enumerates the +catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For +covered kinds, the coverage object lists actual readable and editable +dictionary keys, plus the unmapped snapshot fields and their types. diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index b572786ce..f217de115 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -93,6 +93,10 @@ impl<'a> HostSession<'a> { } #[cfg(not(target_arch = "wasm32"))] { + let key = (self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch); + if self.app.last_plugin_document != Some(key) { + v4_support::publish_drawing_changed(key.0, key.1); + } v4_support::publish_document_view_v4(self.tab_id(), doc); self.app.last_plugin_document = Some((self.tab_id(), self.app.tabs[self.tab].scene.geometry_epoch)); @@ -125,6 +129,73 @@ impl<'a> HostSession<'a> { ok } + pub fn update_entities_transaction( + &mut self, + label: &str, + entities: Vec, + ) -> Result<(), String> { + use std::collections::HashSet; + if label.trim().is_empty() { return Err("transaction label is empty".into()); } + if entities.is_empty() { return Ok(()); } + let mut seen = HashSet::new(); + for entity in &entities { + let handle = entity.common().handle; + if handle.is_null() || !seen.insert(handle) { + return Err(format!("null or duplicate entity handle: {handle:?}")); + } + let Some(existing) = self.document().get_entity(handle) else { + return Err(format!("entity {handle:?} does not exist")); + }; + if std::mem::discriminant(existing) != std::mem::discriminant(entity) { + return Err(format!("entity {handle:?} changes kind")); + } + if existing.common().owner_handle != entity.common().owner_handle { + return Err(format!("entity {handle:?} changes owner")); + } + ocs_plugin_api::entity_coverage::validate_entity_mutation(existing, entity) + .map_err(|error| format!("entity {handle:?}: {error}"))?; + if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + return Err(format!("entity {handle:?} is on a locked layer")); + } + } + self.push_undo(label); + for entity in entities { + // Validation above makes this infallible while the session owns + // the document exclusively. + assert!(self.app.tabs[self.tab].scene.update_entity(entity)); + } + self.set_dirty(); + self.publish_document_view(); + Ok(()) + } + + pub fn selection(&self) -> Vec { + self.app.tabs[self.tab].scene.selected_handles_in_order() + } + + pub fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { + let mut seen = std::collections::HashSet::new(); + for handle in handles { + if !seen.insert(*handle) { + return Err(format!("duplicate selected entity: {handle:?}")); + } + if self.document().get_entity(*handle).is_none() { + return Err(format!("entity {handle:?} does not exist")); + } + } + if self.selection() == handles { return Ok(()); } + let scene = &mut self.app.tabs[self.tab].scene; + scene.replace_selection_exact(handles); + #[cfg(not(target_arch = "wasm32"))] + { + if self.tab == self.app.active_tab { + self.app.last_plugin_selection = Some((self.tab_id(), self.app.tabs[self.tab].scene.selection_fingerprint())); + } + crate::plugin::v4_support::publish_selection_changed_v4(self.tab_id(), self.selection()); + } + Ok(()) + } + /// Delete the entity with `handle`, keeping the scene's render caches in /// sync. Returns `false` when the entity is absent or on a locked layer /// (which `erase_entities` refuses to remove). @@ -188,6 +259,8 @@ impl<'a> HostSession<'a> { if let Some(ah) = app_handle { xd.raw_dwg_eed.retain(|(a, _)| *a != ah); } + self.bump_geometry(); + self.set_dirty(); self.publish_document_view(); true } @@ -225,6 +298,8 @@ impl<'a> HostSession<'a> { if let Some(ah) = app_handle { xd.raw_dwg_eed.retain(|(a, _)| *a != ah); } + self.bump_geometry(); + self.set_dirty(); self.publish_document_view(); true } @@ -322,6 +397,11 @@ impl HostApi for HostSession<'_> { fn update_entity(&mut self, entity: EntityType) -> bool { self.update_entity(entity) } + fn update_entities_transaction(&mut self, label: &str, entities: Vec) -> Result<(), String> { + self.update_entities_transaction(label, entities) + } + fn selection(&self) -> Vec { self.selection() } + fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { self.set_selection(handles) } fn remove_entity(&mut self, handle: Handle) -> bool { self.remove_entity(handle) } @@ -489,6 +569,12 @@ impl PluginProcessInteractiveAdapter { } } +impl Drop for PluginProcessInteractiveAdapter { + fn drop(&mut self) { + let _ = self.process.drop_interactive(self.command_id); + } +} + impl crate::command::CadCommand for PluginProcessInteractiveAdapter { fn name(&self) -> &'static str { "PLUGIN" @@ -559,6 +645,87 @@ mod tests { use acadrust::xdata::XDataValue; use ocs_plugin_api::host::DocumentReader; + #[test] + fn selection_query_is_live_and_replacement_is_validated() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let a = host.add_entity(EntityType::Point(Point::new())); + let b = host.add_entity(EntityType::Point(Point::new())); + assert!(host.selection().is_empty()); + host.set_selection(&[b, a]).unwrap(); + assert_eq!(host.selection(), vec![b, a]); + assert!(host.set_selection(&[Handle::new(99999)]).is_err()); + assert!(host.set_selection(&[a, a]).is_err()); + assert_eq!(host.selection(), vec![b, a]); + host.set_selection(&[]).unwrap(); + assert!(host.selection().is_empty()); + } + + #[test] + fn scripted_selection_does_not_expand_linked_leader_annotation() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let annotation = host.add_entity(EntityType::Text(acadrust::entities::Text::default())); + let mut leader = acadrust::entities::Leader::default(); + leader.annotation_handle = annotation; + let leader_handle = host.add_entity(EntityType::Leader(leader)); + host.set_selection(&[leader_handle]).unwrap(); + assert_eq!(host.selection(), vec![leader_handle]); + } + + #[test] + fn entity_transaction_validates_before_mutation_and_undoes_as_one_step() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let (first, second); + { + let mut host = HostSession::new(&mut app, 0); + first = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(1.0, 0.0, 0.0)))); + second = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(2.0, 0.0, 0.0)))); + let mut a = host.document().get_entity(first).unwrap().clone(); + let mut b = host.document().get_entity(second).unwrap().clone(); + if let EntityType::Point(p) = &mut a { p.location.x = 10.0; } + if let EntityType::Point(p) = &mut b { p.location.x = 20.0; } + let mut invalid = b.clone(); + invalid.common_mut().owner_handle = Handle::new(99999); + assert!(host.update_entities_transaction("Move points", vec![a.clone(), invalid]).is_err()); + let mut invalid_geometry = b.clone(); + if let EntityType::Point(p) = &mut invalid_geometry { p.location.x = f64::NAN; } + assert!(host.update_entities_transaction("Move points", vec![a.clone(), invalid_geometry]) + .unwrap_err().contains("Point.location")); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), 0); + assert!(matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0)); + host.update_entities_transaction("Move points", vec![a, b]).unwrap(); + assert!(matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0)); + } + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 1); + app.undo_steps(1); + assert!(matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0)); + assert!(matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 2.0)); + } + + #[test] + fn unmapped_canvas_kind_layer_change_undoes() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let handle; + { + let mut host = HostSession::new(&mut app, 0); + handle = host.add_entity(EntityType::Hatch(acadrust::entities::Hatch::default())); + let original = host.document().get_entity(handle).unwrap(); + let changed = ocs_plugin_api::entity_coverage::patch_canvas_layer(original, "HATCHES").unwrap(); + host.update_entities_transaction("Move hatch layer", vec![changed]).unwrap(); + assert_eq!(host.document().get_entity(handle).unwrap().common().layer, "HATCHES"); + } + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 1); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle).unwrap().common().layer, "0"); + } + #[test] fn system_variables_change_without_command_reentry() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e0f9a23a4..d498abbac 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -276,6 +276,7 @@ impl OpenCADStudio { return; } self.last_plugin_document = Some(key); + crate::plugin::v4_support::publish_drawing_changed(tab.id, tab.scene.geometry_epoch); crate::plugin::v4_support::publish_document_view_v4(tab.id, &tab.scene.document); } @@ -305,7 +306,22 @@ impl OpenCADStudio { return Task::none(); } } + #[cfg(not(target_arch = "wasm32"))] + let command_before = self.tabs.get(self.active_tab).map(|tab| { + (tab.id, tab.active_cmd.as_ref().map(|command| command.name().to_owned())) + }); let task = self.update_inner(msg); + #[cfg(not(target_arch = "wasm32"))] + { + let command_after = self.tabs.get(self.active_tab).map(|tab| { + (tab.id, tab.active_cmd.as_ref().map(|command| command.name().to_owned())) + }); + if command_before != command_after { + if let Some((tab_id, command)) = command_after { + crate::plugin::v4_support::publish_command_state_changed(tab_id, command); + } + } + } self.refresh_gpu_status(); self.show_next_startup_modal(); self.sync_open_command_history(); diff --git a/src/plugin/v4_support.rs b/src/plugin/v4_support.rs index 59259f0fc..e8b7eab42 100644 --- a/src/plugin/v4_support.rs +++ b/src/plugin/v4_support.rs @@ -51,6 +51,16 @@ pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec) broadcast(HostNotification::SelectionChangedV4 { tab_id, handles }); } +/// Broadcast a change in a tab's active interactive command. +pub fn publish_command_state_changed(tab_id: u64, command: Option) { + broadcast(HostNotification::CommandStateChanged { tab_id, command }); +} + +/// Broadcast drawing changes independently of shared-view subscriptions. +pub fn publish_drawing_changed(tab_id: u64, epoch: u64) { + broadcast(HostNotification::DrawingChanged { tab_id, epoch }); +} + /// V4 notification handler installed on the plugin manager. /// /// Forwards REPL status messages to the log; other notifications are handled diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 8a5ee23f4..b5a66886c 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -525,6 +525,9 @@ impl Scene { let Some(existing) = self.document.get_entity(handle) else { return false; }; + // IPC snapshots omit storage-only payloads (notably Unknown/Extended + // entities). Preserve them when replacing a clone from a plugin. + entity.preserve_storage_data_from(existing); // The caller edited a snapshot copy; keep the live entity in its block. entity.common_mut().owner_handle = existing.common().owner_handle; diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 4ebab412c..f5684e31f 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -144,6 +144,16 @@ impl Scene { self.bump_selection_set(); } + /// Script-facing replacement: preserve exactly the validated input order. + /// UI picks still use `select_entity` and its leader/annotation expansion. + pub(crate) fn replace_selection_exact(&mut self, handles: &[Handle]) { + if self.selected_handles_in_order() == handles { return; } + self.selected_constraint = None; + self.selected = handles.iter().copied().collect(); + self.selected_order = handles.to_vec(); + self.bump_selection_set(); + } + pub fn select_all_visible(&mut self) -> usize { let block = self.interaction_block_handle(); let frozen: Option> = self From db61379bfe555b77b2187e1a73d2b131b123c2b0 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 01:20:20 +0200 Subject: [PATCH 04/76] Add validated Insert transform scripting coverage --- crates/ocs_plugin_api/build.rs | 16 ++-- .../entity_coverage_policy.json | 6 +- crates/ocs_plugin_api/src/entity_coverage.rs | 76 ++++++++++++++++++- docs/plugin-host-model.md | 15 +++- src/app/plugin_host.rs | 25 ++++++ 5 files changed, 126 insertions(+), 12 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 765360e34..782b81cce 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -24,6 +24,7 @@ struct EntityCoveragePolicy { internal_kinds: Vec, opaque_kinds: Vec, editable: BTreeMap>, + readable: BTreeMap>, aliases: BTreeMap>, } @@ -510,7 +511,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { let variants = ®istry.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.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 { @@ -532,8 +533,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { else if policy.opaque_kinds.contains(kind) { EntityScope::Opaque } else { EntityScope::Canvas }; let editable = policy.editable.get(kind).cloned().unwrap_or_default(); - let mut unresolved: HashSet = editable.iter().cloned().collect(); - assert_eq!(unresolved.len(), editable.len(), "duplicate editable property in {kind}"); + let readable = policy.readable.get(kind).cloned().unwrap_or_default(); + let mut unresolved: HashSet = 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: "".into(), type_id: "String".into(), @@ -563,13 +565,13 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { let shape = ®istry.types[&shape_type]; for field in &shape.fields { if field.name == "common" { continue; } - let exposed = editable.iter().find(|name| { + 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(), ModelAccess::ReadWrite) + ((*name).clone(), if editable.contains(name) { ModelAccess::ReadWrite } else { ModelAccess::ReadOnly }) } None => (field.name.clone(), ModelAccess::Unmapped), }; @@ -579,7 +581,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { ("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")) { + ("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")) { "transaction_geometry" } else { "type_conversion_only" }; properties.push(PropertyCoverage { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 513af55a4..cff46a247 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -17,7 +17,11 @@ "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"] + "Face3D": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "invisible_edges"], + "Insert": ["layer", "insert_point", "x_scale", "y_scale", "z_scale", "rotation", "normal", "column_count", "row_count", "column_spacing", "row_spacing"] + }, + "readable": { + "Insert": ["block_name"] }, "aliases": { "Polyline": {"closed": "flags"}, diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 5634c542f..3f8131c1d 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -41,6 +41,12 @@ fn solid_normal(name: &str, value: &crate::host::acadrust::types::Vector3) -> Re else { Err(format!("{name} must be nonzero")) } } +#[cfg(feature = "host")] +fn insert_scale(name: &str, value: f64) -> Result<(), String> { + if value.is_finite() && value.abs() >= 1e-12 { Ok(()) } + else { Err(format!("{name} must be finite and nonzero")) } +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -167,6 +173,34 @@ pub fn validate_entity_mutation( return Err("Face3D.invisible_edges has unknown bits".into()); } } + (EntityType::Insert(old), EntityType::Insert(new)) => { + if old.block_name != new.block_name || old.attributes != new.attributes || + old.view_rep_handle != new.view_rep_handle || old.seqend_handle != new.seqend_handle { + return Err("Insert block identity and attached records cannot change in a geometry transaction".into()); + } + changed3("Insert.insert_point", &old.insert_point, &new.insert_point)?; + if !same3(&old.normal, &new.normal) { solid_normal("Insert.normal", &new.normal)?; } + for (name, before, after) in [ + ("x_scale", old.x_scale(), new.x_scale()), + ("y_scale", old.y_scale(), new.y_scale()), + ("z_scale", old.z_scale(), new.z_scale()), + ] { + if before.to_bits() != after.to_bits() { insert_scale(&format!("Insert.{name}"), after)?; } + } + for (name, before, after) in [ + ("rotation", old.rotation, new.rotation), + ("column_spacing", old.column_spacing, new.column_spacing), + ("row_spacing", old.row_spacing, new.row_spacing), + ] { + if before.to_bits() != after.to_bits() && !after.is_finite() { + return Err(format!("Insert.{name} must be finite")); + } + } + if (old.column_count != new.column_count && new.column_count == 0) || + (old.row_count != new.row_count && new.row_count == 0) { + return Err("Insert array counts must be greater than zero".into()); + } + } _ => {} } Ok(()) @@ -222,7 +256,7 @@ mod tests { if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(), "Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" | "Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" | - "Ray" | "XLine" | "Solid" | "Face3D") { + "Ray" | "XLine" | "Solid" | "Face3D" | "Insert") { assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite || (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind); } @@ -249,6 +283,11 @@ mod tests { "Face3D.first_corner".to_owned(), "Face3D.second_corner".to_owned(), "Face3D.third_corner".to_owned(), "Face3D.fourth_corner".to_owned(), "Face3D.invisible_edges".to_owned(), + "Insert.insert_point".to_owned(), "Insert.x_scale".to_owned(), + "Insert.y_scale".to_owned(), "Insert.z_scale".to_owned(), + "Insert.rotation".to_owned(), "Insert.normal".to_owned(), + "Insert.column_count".to_owned(), "Insert.row_count".to_owned(), + "Insert.column_spacing".to_owned(), "Insert.row_spacing".to_owned(), ])); } @@ -265,6 +304,17 @@ mod tests { } } + #[test] + fn insert_catalog_separates_transform_writes_from_block_identity() { + let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + let insert = catalog.entity_kinds.iter().find(|entry| entry.kind == "Insert").unwrap(); + let block_name = insert.properties.iter().find(|property| property.name == "block_name").unwrap(); + assert_eq!(block_name.model_access, ModelAccess::ReadOnly); + assert_eq!(block_name.validation, "none"); + assert!(insert.properties.iter().find(|property| property.name == "attributes").unwrap().model_access == ModelAccess::Unmapped); + assert!(insert.properties.iter().find(|property| property.name == "insert_point").unwrap().model_access == ModelAccess::ReadWrite); + } + #[cfg(feature = "host")] #[test] fn unsupported_canvas_kind_has_typed_snapshot_fields() { @@ -326,6 +376,30 @@ mod tests { .unwrap_err().contains("Face3D.invisible_edges")); } + #[cfg(feature = "host")] + #[test] + fn insert_transaction_checks_transforms_and_protects_references() { + use crate::host::acadrust::{self, types::Vector3}; + let before = acadrust::entities::Insert::new("DOOR", Vector3::new(1.0, 2.0, 0.0)); + let mut moved = before.clone(); + moved.insert_point = Vector3::new(5.0, 6.0, 0.0); + moved.set_x_scale(2.0); + assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before.clone()), + &acadrust::EntityType::Insert(moved)).is_ok()); + let mut bad = before.clone(); + bad.set_y_scale(f64::NAN); + assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before.clone()), + &acadrust::EntityType::Insert(bad)).unwrap_err().contains("Insert.y_scale")); + let mut bad = before.clone(); + bad.column_count = 0; + assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before.clone()), + &acadrust::EntityType::Insert(bad)).unwrap_err().contains("array counts")); + let mut bad = before.clone(); + bad.block_name = "OTHER".into(); + assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before), + &acadrust::EntityType::Insert(bad)).unwrap_err().contains("block identity")); + } + #[cfg(feature = "host")] #[test] fn canvas_layer_patch_preserves_unmapped_hatch_fields() { diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 1eea48b33..37bcb69f2 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -28,11 +28,14 @@ The v7 transaction path checks changed Point locations, Line endpoints, Circle/Arc centers, Ray/XLine base points, and Solid/Face3D corners for finite coordinates. Circle/Arc radii must be finite and positive; Ray/XLine directions must be unit vectors; Solid normals must be nonzero, its thickness finite, -and Face3D invisible-edge flags limited to four known bits. Those fields report `transaction_geometry` in the +and Face3D invisible-edge flags limited to four known bits. Insert transactions +validate placement, finite rotation and spacing, nonzero finite scale and normal, +and positive array counts. They reject changes to the referenced block and +attached attribute and sequence records. Those fields report `transaction_geometry` in the catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 16 geometry converters. Other writable properties +canvas kinds outside its 17 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -45,7 +48,11 @@ null and cannot be used as a lossless replacement entity. The separate PandoraBox adapter currently maps these entity kinds to Python dictionaries: Point, Line, Circle, Arc, Ellipse, Polyline, Polyline2D, -Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, and Face3D. The authoritative per-field mapping +Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, and +Insert. Insert is update-only: the block name is readable but cannot be +changed, and Python creation is withheld until block-table references can be +established safely. Attached attributes remain available in the raw snapshot. +The authoritative per-field mapping is `plugins/opencad-python/entity_manifest.json` in PandoraBox. The generated mapping excludes fields it cannot represent, including common color, line weight, transparency, Polyline3D smooth type, and MText background color. @@ -81,7 +88,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 16-kind generated schema it contains only handle, kind, and layer; +outside the 17-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index f217de115..907a24dd5 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -726,6 +726,31 @@ mod tests { assert_eq!(app.tabs[0].scene.document.get_entity(handle).unwrap().common().layer, "0"); } + #[test] + fn insert_transform_transaction_undoes_without_changing_block_identity() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let handle; + { + let mut host = HostSession::new(&mut app, 0); + let insert = acadrust::entities::Insert::new("DOOR", acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + handle = host.add_entity(EntityType::Insert(insert)); + let mut changed = host.document().get_entity(handle).unwrap().clone(); + if let EntityType::Insert(insert) = &mut changed { + insert.insert_point.x = 4.0; + insert.set_x_scale(2.0); + } + host.update_entities_transaction("Move block", vec![changed]).unwrap(); + assert!(matches!(host.document().get_entity(handle), Some(EntityType::Insert(insert)) + if insert.insert_point.x == 4.0 && insert.x_scale() == 2.0 && insert.block_name == "DOOR")); + } + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 1); + app.undo_steps(1); + assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Insert(insert)) + if insert.insert_point.x == 1.0 && insert.x_scale() == 1.0 && insert.block_name == "DOOR")); + } + #[test] fn system_variables_change_without_command_reentry() { let mut app = OpenCADStudio::new_for_test(); From 4dda2dace9b821c5e9ca22869718d904d0ba5824 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 01:56:25 +0200 Subject: [PATCH 05/76] Add host model integration compatibility tests --- crates/ocs_plugin_api/src/process/v4.rs | 56 ++++++ docs/plugin-host-model-completion-plan.md | 106 ++++++++++ docs/plugin-host-model-coverage-ledger.md | 53 +++++ src/app/plugin_host.rs | 231 ++++++++++++++++++++++ 4 files changed, 446 insertions(+) create mode 100644 docs/plugin-host-model-completion-plan.md create mode 100644 docs/plugin-host-model-coverage-ledger.md diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index fbf5edf3b..1edac6e5b 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -444,16 +444,21 @@ mod tests { struct DummyHost { push_info_messages: StdMutex>, + transactions: StdMutex)>>, } impl DummyHost { fn new() -> Self { Self { push_info_messages: StdMutex::new(Vec::new()), + transactions: StdMutex::new(Vec::new()), } } fn take_push_info(&self) -> Vec { std::mem::take(&mut *self.push_info_messages.lock().unwrap()) } + fn take_transactions(&self) -> Vec<(String, Vec)> { + std::mem::take(&mut *self.transactions.lock().unwrap()) + } } impl HostApi for DummyHost { fn tab_index(&self) -> usize { @@ -490,6 +495,14 @@ mod tests { false } fn push_undo(&mut self, _label: &str) {} + fn update_entities_transaction( + &mut self, + label: &str, + entities: Vec, + ) -> 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()); @@ -661,6 +674,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, 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::(&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::(&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(); diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md new file mode 100644 index 000000000..014422af5 --- /dev/null +++ b/docs/plugin-host-model-completion-plan.md @@ -0,0 +1,106 @@ +# OCS canvas entity completion plan + +## Goal and current state + +Build a general Python document model over the OCS host API for all **43 canvas kinds**. A kind is **complete** only after the per-kind gate below passes. Merely appearing in a converter or allowing a `layer` edit is partial coverage. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). Do not resume the separate Lisp-to-Python conversion work. + +As of 19 September 2026, the OCS fork branch `plugin/host-model-api` is pushed at `db61379b` in `felixriestra/OpenCADStudio`; the PandoraBox adapter on `main` is pushed at `d0cc85e` in `felixriestra/PandoraBox`. Host API is v7. The Python feature has mapped writes for 16 initial geometry kinds plus **update-only** `Insert`; this is **not** 17 completed kinds. The other 26 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. + +Repositories: + +- OCS host: `/Users/felix/Documents/MacApps/OpenCADStudio` +- Python adapter: `/Users/felix/Documents/MacApps/opencad-python/plugins/opencad-python` +- Host coverage policy: `crates/ocs_plugin_api/entity_coverage_policy.json` +- Host coverage/validation: `crates/ocs_plugin_api/src/entity_coverage.rs` and `build.rs` +- Adapter mapping: `entity_manifest.json`, `build/generate.rs`, `src/ocs_module.rs`, `src/document_model.py` +- Host design notes: `docs/plugin-host-model.md` + +Preserve unrelated untracked files in both repositories. Push OCS changes only to the `felixriestra/OpenCADStudio` fork, not H7 upstream; do not open a PR without asking. The prior user approved pushing work to the existing OCS and PandoraBox repositories, but verify remote branch tips before each push. + +## First: complete the originally agreed Phase 8 + +The earlier Phase 8 was **integration and compatibility**, not a new entity kind. `Insert` was added afterward as a separate coverage increment. Before marking any kind complete, establish an executable integration harness that exercises the actual plugin runner and IPC, not only direct Rust conversion helpers: + +1. Stage the development v7 plugin with `bash plugins/opencad-python/tools/stage-host-model.sh [--debug]`. Verify the staged `plugin.toml` says API v7 and its acadrust source matches OCS. Keep checked-in PandoraBox `plugin.toml` and `Cargo.lock` on H7 API v5; keep `Cargo.toml` free of machine-local paths. +2. Through real IPC, create, inspect, edit, and delete a representative entity. Verify an invalid multi-entity transaction changes nothing, a valid transaction produces one undo entry, and undo **and redo** restore the expected geometry and references. +3. In the running GUI, exercise a real point pick, entity pick, cancellation/Escape, and plugin command cancellation. Verify drawing/selection/command notifications, including overflow recovery. +4. Repeat an input request and event poll across two tabs; verify token and notification isolation and cleanup when a tab closes. +5. Save and reopen representative edits as **DWG and DXF**, verifying the actual geometry and preserved unmapped fields. Use a real viewport/canvas check where the entity has visible geometry; a converter round trip alone does not satisfy this. +6. Record platform, OCS/acadrust revisions, fixture files, commands, results, and any engine limitation. Keep repeatable tests in the relevant repositories. + +Phase 8 is complete only when these checks pass without weakening the portable v5 build. If a test is blocked by OCS or acadrust behavior, record the specific failure and leave Phase 8 open. + +## Per-kind completion gate + +Use one checklist row per kind in a coverage ledger. Status values: `unstarted`, `mapped`, `integration-tested`, `complete`, or `blocked`; record the blocking dependency. **Do not use “complete” for read-only, update-only, or layer-only support.** For every kind: + +1. **Declare coverage:** Every traced field has an accurate catalog status and validation rule. Distinguish ordinary properties from handles, owners, linked records, external assets, and opaque payloads. Snapshot-only fields remain explicitly unmapped. Assert adapter keys match the host catalog. +2. **Create:** A Python script creates a valid entity through the document model in a real document with required tables, styles, definitions, files, and references. Missing or invalid required inputs fail before mutation. Add document-model creation/deletion methods if needed; current `doc.entities` only indexes and iterates, while low-level `ocs.add`/remove APIs exist. +3. **Read and edit:** After creation, read the entity by handle through `doc.entities`; edit at least one defining geometry or placement field and one other meaningful writable field inside `doc.transaction`. Verify that all unmentioned fields, handles, ownership, attached records, and XDATA remain unchanged. Check invalid values and forbidden reference edits fail atomically. +4. **Delete and history:** Delete the created entity through the Python interface. Prove create, edit, and delete have defined undo/redo behavior in the host, including grouped writes and failure rollback. Record any transaction API extension and bump the host API version if its ABI changes; keep older plugin compatibility. +5. **Real IPC and canvas:** Run the above through the staged plugin and actual OCS IPC. Verify visibility/geometry in the canvas and selection or picking in the GUI. For nonvisual entities, verify their observable document or layout behavior and explain the oracle. +6. **Persistence:** Save/reopen DWG **and** DXF fixtures. Compare kind, geometry, important metadata, handles/references, and preserved fields after reopen; edit again after reopen. Test at least one valid edge case for that kind. Do not count a JSON or in-memory round trip as file persistence. +7. **Portable build:** Run feature tests with a command-line Cargo patch to the OCS fork, restore PandoraBox `Cargo.lock`, then run `cargo check --locked` against the checked-in H7 v5 dependency. Never check in a local Cargo path or v7 portable manifest. + +If OCS/acadrust cannot create or serialize a kind reliably, mark it **blocked**, add a regression test or fixture demonstrating the failure, and move to the next independent kind. An explicit unsupported status is honest progress, not completion. + +## Existing mapped kinds: audit before claiming completion + +Audit these against the same gate: `Point`, `Line`, `Circle`, `Arc`, `Ellipse`, `Polyline`, `Polyline2D`, `Polyline3D`, `LwPolyline`, `Spline`, `Text`, `MText`, `Ray`, `XLine`, `Solid`, `Face3D`, `Insert`. `Insert` currently has transform edits but **no Python creation**; its block name is read-only and attached attributes are snapshot-only. Earlier tests include some direct DWG round trips, but there is no evidence that every mapped kind passes real IPC, GUI, deletion, undo/redo, and both file formats. Mark each independently after testing. + +## Order for the 26 canvas kinds without mapped geometry writes + +The order below groups shared implementation work. Complete a kind only through the gate above. Within a group, move past a blocked kind after recording the exact dependency. “Minimum exercise” identifies a real entity-specific create/edit/delete case; it does not make other fields automatically writable. + +| # | Kind | Minimum entity-specific exercise and dependency | +|---:|---|---| +| 1 | Tolerance | Create a feature-control frame with text/style; move and edit its text/direction; check style reference and display. | +| 2 | Shape | Create with an installed SHX shape/style; change insertion, size, rotation; verify the named glyph and style survive reopen. | +| 3 | AttributeDefinition | Create in a block definition; edit tag, default text, and placement; verify the block’s definition and linked inserts. | +| 4 | AttributeEntity | Create as a block reference attribute; edit value and placement; verify parent `Insert`, handles, and sequence linkage. | +| 5 | Hatch | Create a closed-boundary solid hatch and a patterned hatch; edit boundary/pattern; verify fill and boundary validity. | +| 6 | Leader | Create with vertices and an annotation; edit path and annotation placement; preserve annotation handle. | +| 7 | MLine | Create against a valid MLine style; edit vertices and scale; verify style element offsets and joins. | +| 8 | Dimension | Create at least one supported dimension subtype with style and reference points; edit defining geometry and displayed measurement. Track subtype coverage separately. | +| 9 | MultiLeader | Create text and leader paths using a valid style; edit landing/vertices and text; preserve nested context and annotation links. | +| 10 | Table | Create rows, columns, style, and cells; edit size and one cell value; preserve merged ranges and block/style links. | +| 11 | PolygonMesh | Create a grid with defined M/N counts and vertices; edit a vertex and density; reject inconsistent counts. | +| 12 | PolyfaceMesh | Create vertices/faces; edit a vertex and face indices; reject invalid indices and preserve sequence records. | +| 13 | Mesh | Create vertices/faces/edges; edit one vertex and topology; reject out-of-range indices and verify rendered faces. | +| 14 | Helix | Create with axis, radius, turns, and height; edit a defining parameter and verify the derived curve plus embedded spline data. | +| 15 | RasterImage | Create with a resolvable image definition/file; edit placement, vectors, and clipping; verify display and definition handles. | +| 16 | Wipeout | Create with a valid clip polygon; edit boundary and placement; verify masking, clip mode, and image-definition links. | +| 17 | Underlay | Create with a valid PDF/DWF/DGN definition; edit transform and clipping; verify external resource and definition handle. | +| 18 | Viewport | Create in the appropriate layout; edit center/size/view target; verify visible viewport and frozen-layer/clip references. | +| 19 | ViewBorder | Create with its required view/scale references; edit border geometry; verify linked viewport/view records. | +| 20 | SectionSymbol | Create with view style and point records; edit endpoints/label; preserve view-representation links. | +| 21 | Light | Create a light with position/target; edit intensity and direction; verify color/photometric state and visible glyph. | +| 22 | Region | Create/import a valid region payload; transform/edit supported geometry, then verify ACIS payload after both file formats. | +| 23 | Body | Create/import a valid body payload; apply a supported transform/edit; verify ACIS and history/reference preservation. | +| 24 | Solid3D | Create/import a valid solid; edit a supported geometric operation; verify ACIS, topology, and history after reopen. | +| 25 | Surface | Create/import a valid surface; edit a supported parameter/transform; verify surface payload, isolines, and history. | +| 26 | Ole2Frame | Create with a valid embedded object payload; edit frame geometry; verify storage, aspect behavior, and persistence. | + +The last five may require new engine capabilities rather than only Python converters. Do not deserialize and rewrite their opaque payloads unless lossless behavior is proven. If a listed kind has no meaningful safe Python creation/edit path, keep it marked blocked or partial and report why; do not relabel it complete to reach 43/43. + +## Work pattern for each coding increment + +1. Inspect the acadrust entity struct, constructors, private fields, serializer, drawing path, and linked tables/objects. Record the intended editable/read-only/unmapped properties first. +2. Put generic validation and transaction behavior in the OCS fork. Keep the Python adapter thin; add a manifest override only when the generator cannot safely express a field. +3. Add focused host validation, adapter conversion, document-model, real transaction/undo/redo, IPC, GUI, and DWG/DXF fixture checks. Record test evidence in the ledger, including which checks were manual. +4. Run `cargo test -p ocs_plugin_api --features host --lib` and relevant app host tests in OCS. For the adapter, use `cargo test --features experimental-host-model --config 'patch."https://github.com/HakanSeven12/OpenCADStudio".ocs_plugin_api.path="/Users/felix/Documents/MacApps/OpenCADStudio/crates/ocs_plugin_api"' --lib`, run its Python tests, restore `Cargo.lock`, then run `cargo check --locked`. +5. Check both `git diff --check` and repository status. Stage only task files, preserve unrelated untracked files, commit coherent host/adapter changes, and push only to the two established `felixriestra` remotes if still authorized. Record the resulting commits and next unfinished gate in the ledger. + +## Handoff start point + +Start with the **Phase 8 integration harness and coverage ledger**. Re-evaluate the existing 17 mapped kinds against the strict completion gate. Then take `Tolerance` as the first new kind, unless its required style or persistence path is blocked; record that and proceed to `Shape`. Keep the exact remaining-kind table above as the queue and update statuses with evidence after each increment. + +## Phase 8 progress log + +- Added a V4 local-socket nested-request test for `UpdateEntitiesTransaction` using a simulated runner peer and a recording host. This proves the request and response cross the V4 IPC framing and reach `HostApi`; it does **not** prove the actual Python plugin runs through IPC. +- Extended the OCS app-host Point batch test to assert redo after undo. This proves the real app history path for those edits; creation/deletion history remains untested by this case. +- Staged the actual debug Python cdylib with `stage-host-model.sh`. Its generated manifest declares API v7 and the OCS acadrust source; the checked-in PandoraBox manifest and lockfile remain on H7 v5, and `cargo check --locked` passes. +- The opt-in `staged_python_plugin_line_lifecycle_over_real_ipc` app test passed with the staged v7 Python plugin and built OCS executable. It creates, edits, and deletes a Line through Python over the actual runner IPC; confirms three undo entries and undo/redo; and writes/reopens the edited Line as DWG and DXF. It does not exercise GUI picking, multiple tabs, or cancellation. Re-run with `OCS_TEST_PYTHON_PLUGIN= OCS_PLUGIN_RUNNER_EXE= cargo test app::plugin_host::tests::staged_python_plugin_line_lifecycle_over_real_ipc --lib -- --test-threads=1 --nocapture` from the OCS checkout. +- The same Line test now also sends a duplicate-handle batch through Python and confirms the host rejects it without changing geometry or creating an undo entry. +- The opt-in `staged_python_point_pick_and_cancel_over_real_ipc` test passes with the same runner. It requests point and entity picks through Python, drives the app's active-command point/object-pick callbacks, polls the returned tokens through a later Python run, and verifies Enter cancellation. The callbacks are invoked by the test harness, not by a physical GUI pointer event; live GUI verification remains open. +- The opt-in `staged_python_tabs_isolate_tokens_and_notifications` test passes with two real OCS document tabs and the staged runner. A token created in tab 0 cannot be observed or consumed in tab 1 before or after completion. Host-to-plugin DrawingChanged notifications for both tab ids are delivered over IPC and drained only by their owning tab. A 257-event burst reports overflow, and `DocumentTabClosed` discards queued events for that tab. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md new file mode 100644 index 000000000..8369bdd46 --- /dev/null +++ b/docs/plugin-host-model-coverage-ledger.md @@ -0,0 +1,53 @@ +# OCS canvas kind coverage ledger + +Use with [the completion plan](plugin-host-model-completion-plan.md). The baseline below is conservative as of 19 September 2026. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. + +Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = portable H7 v5 build. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. + +| Kind | Baseline | Gate evidence / blocker | +|---|---|---| +| Point | Mapped | Host transaction test now checks invalid batch rollback, grouped edit undo, and redo; creation/deletion history, real plugin execution, GUI, and DWG/DXF gates remain pending. | +| Line | Mapped | Actual staged-plugin IPC Python create/edit/delete, three undo entries and undo/redo, and edited DWG/DXF reopen pass in `staged_python_plugin_line_lifecycle_over_real_ipc`. GUI pick/visibility, invalid-input/reference preservation, and document-model create/delete API still pending; not complete. | +| Circle | Mapped | Full gate pending. | +| Arc | Mapped | Full gate pending. | +| Ellipse | Mapped | Existing focused DWG conversion test; full gate pending. | +| Polyline | Mapped | Full gate pending. | +| Polyline2D | Mapped | Full gate pending. | +| Polyline3D | Mapped | Full gate pending. | +| LwPolyline | Mapped | Full gate pending. | +| Spline | Mapped | Full gate pending. | +| Text | Mapped | Full gate pending. | +| MText | Mapped | Full gate pending. | +| Ray | Mapped | Focused DWG round trip; full gate pending. | +| XLine | Mapped | Focused DWG round trip; full gate pending. | +| Solid | Mapped | Focused DWG round trip; full gate pending. | +| Face3D | Mapped | Focused DWG round trip; full gate pending. | +| Insert | Update-only | Transform transaction and undo tested; Python creation, deletion, real IPC/GUI and DWG/DXF gates pending. | +| Tolerance | Layer-only | Next new kind after integration harness. | +| Shape | Layer-only | Requires valid shape/style fixture. | +| AttributeDefinition | Layer-only | Requires block-definition fixture. | +| AttributeEntity | Layer-only | Requires linked Insert and attribute-sequence fixture. | +| Hatch | Layer-only | Requires valid boundary and pattern fixtures. | +| Leader | Layer-only | Requires annotation-linked fixture. | +| MLine | Layer-only | Requires valid style and vertex fixture. | +| Dimension | Layer-only | Track individual dimension subtypes. | +| MultiLeader | Layer-only | Requires nested context and style fixture. | +| Table | Layer-only | Requires table style and cell fixture. | +| PolygonMesh | Layer-only | Requires consistent grid/count fixture. | +| PolyfaceMesh | Layer-only | Requires valid face-index fixture. | +| Mesh | Layer-only | Requires valid topology fixture. | +| Helix | Layer-only | Requires valid derived-spline fixture. | +| RasterImage | Layer-only | Requires image definition and file fixture. | +| Wipeout | Layer-only | Requires clip and image-definition fixture. | +| Underlay | Layer-only | Requires external definition/file fixture. | +| Viewport | Layer-only | Requires layout and view-state fixture. | +| ViewBorder | Layer-only | Requires linked view records. | +| SectionSymbol | Layer-only | Requires section-view style and point records. | +| Light | Layer-only | Requires position/target and photometric-state fixture. | +| Region | Layer-only | Requires valid ACIS region payload. | +| Body | Layer-only | Requires valid ACIS body payload. | +| Solid3D | Layer-only | Requires valid ACIS solid payload. | +| Surface | Layer-only | Requires valid surface payload. | +| Ole2Frame | Layer-only | Requires valid embedded-object storage. | + +After each increment, update the row with test names, fixture paths, exact passing commands, OCS and PandoraBox commit hashes, and any manual GUI result. If a gate is untested, leave it pending. Do not raise the headline completion count from converter coverage alone. diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 907a24dd5..1fe1398b2 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -705,6 +705,237 @@ mod tests { app.undo_steps(1); assert!(matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0)); assert!(matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 2.0)); + app.redo_steps(1); + assert!(matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0)); + assert!(matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 20.0)); + } + + /// Opt-in end-to-end check using the staged Python cdylib and the actual + /// OCS executable as its out-of-process runner. Run with OCS_TEST_PYTHON_PLUGIN + /// and OCS_PLUGIN_RUNNER_EXE set; unlike the local-socket protocol test, + /// this executes Python and calls the app's HostSession over real IPC. + #[test] + fn staged_python_plugin_line_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let plugin_path = std::path::PathBuf::from(plugin_path); + assert!(plugin_path.is_file(), "missing staged Python plugin: {}", plugin_path.display()); + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + &plugin_path, &mut host, crate::plugin::v4_support::notification_handler(), + ).expect("spawn staged Python plugin"); + assert_eq!(process.id(), "opencad.python"); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + dispatch(&mut host, concat!( + "PY_EVAL ocs.add({'kind':'Line',", + "'start':{'x':0.0,'y':0.0,'z':0.0},", + "'end':{'x':1.0,'y':0.0,'z':0.0}})" + )); + let handles: Vec<_> = host.document().entities().map(|entity| entity.common().handle).collect(); + assert_eq!(handles.len(), 1, "Python add did not reach the host"); + let handle = handles[0]; + assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0)); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Move line', [{{'handle':{},'end':{{'x':5.0,'y':0.0,'z':0.0}}}}])", + handle.value() + )); + assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject duplicate', [{{'handle':{},'end':{{'x':8.0,'y':0.0,'z':0.0}}}},{{'handle':{},'end':{{'x':9.0,'y':0.0,'z':0.0}}}}])", + handle.value(), handle.value() + )); + assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0), + "rejected batch changed the line"); + let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).expect("write edited DWG"); + let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes)) + .read().expect("reopen edited DWG"); + assert!(matches!(dwg_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)); + let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().expect("write edited DXF"); + let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes)) + .expect("open edited DXF").read().expect("reopen edited DXF"); + assert!(matches!(dxf_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)); + dispatch(&mut host, &format!("PY_EVAL ocs.remove_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none(), "Python removal did not reach the host"); + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3, + "create, edit, and delete should each form one undo entry"); + app.undo_steps(1); + assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)); + app.undo_steps(1); + assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + + #[test] + fn staged_python_point_pick_and_cancel_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let process = { + let mut host = HostSession::new(&mut app, 0); + std::sync::Arc::new(ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, + crate::plugin::v4_support::notification_handler(), + ).expect("spawn staged Python plugin")) + }; + let request = |app: &mut OpenCADStudio, prompt: &str, entity: bool| { + let mut started = None; + { + let mut host = HostSession::new(app, 0); + let method = if entity { "request_entity" } else { "request_point" }; + let cmd = format!("PY_EVAL ocs.active_document.{method}('{prompt}')"); + assert!(process.dispatch(&mut host, &cmd, &mut |id| started = Some(id)).unwrap()); + } + let output = app.command_line.history.last().unwrap().text.clone(); + let token = output.split_whitespace().find_map(|part| part.parse::().ok()) + .unwrap_or_else(|| panic!("Python pick token missing from {output:?}")); + let id = started.expect("Python request started an interactive command"); + app.set_active_command(0, Box::new(PluginProcessInteractiveAdapter::new( + std::sync::Arc::clone(&process), id, + ))); + token + }; + + let picked_token = request(&mut app, "Pick a point", false); + let result = app.tabs[0].active_cmd.as_mut().unwrap().on_point(glam::DVec3::new(3.0, 4.0, 0.0)); + let _ = app.apply_cmd_result(result); + assert!(app.tabs[0].active_cmd.is_none()); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({picked_token})"), &mut |_| {}).unwrap()); + } + assert!(app.command_line.history.last().unwrap().text.contains("point")); + + let target = { + let mut host = HostSession::new(&mut app, 0); + host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(6.0, 7.0, 0.0)))) + }; + let entity_token = request(&mut app, "Pick an entity", true); + assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick()); + let result = app.tabs[0].active_cmd.as_mut().unwrap() + .on_entity_pick(target, glam::DVec3::new(6.0, 7.0, 0.0)); + let _ = app.apply_cmd_result(result); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({entity_token})"), &mut |_| {}).unwrap()); + } + assert!(app.command_line.history.last().unwrap().text.contains("entity")); + assert!(app.command_line.history.last().unwrap().text.contains(&target.value().to_string())); + + let cancelled_token = request(&mut app, "Cancel a point", false); + let result = app.tabs[0].active_cmd.as_mut().unwrap().on_enter(); + let _ = app.apply_cmd_result(result); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({cancelled_token})"), &mut |_| {}).unwrap()); + } + assert!(app.command_line.history.last().unwrap().text.contains("cancelled")); + } + + #[test] + fn staged_python_tabs_isolate_tokens_and_notifications() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + app.tabs.push(crate::app::document::DocumentTab::new_drawing(2)); + app.tabs[1].is_start = false; + let tab0 = app.tabs[0].id; + let tab1 = app.tabs[1].id; + assert_ne!(tab0, tab1); + let process = { + let mut host = HostSession::new(&mut app, 0); + std::sync::Arc::new(ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, + crate::plugin::v4_support::notification_handler(), + ).expect("spawn staged Python plugin")) + }; + let mut started = None; + { + let mut host = HostSession::new(&mut app, 0); + assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.request_point('Tab zero')", + &mut |id| started = Some(id)).unwrap()); + } + let token = app.command_line.history.last().unwrap().text.split_whitespace() + .find_map(|part| part.parse::().ok()).expect("pick token"); + app.set_active_command(0, Box::new(PluginProcessInteractiveAdapter::new( + std::sync::Arc::clone(&process), started.unwrap(), + ))); + { + let mut host = HostSession::new(&mut app, 1); + assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({token})"), &mut |_| {}).unwrap()); + } + assert!(app.command_line.history.last().unwrap().text.contains("None"), + "other tab must not consume the pending token"); + let result = app.tabs[0].active_cmd.as_mut().unwrap().on_point(glam::DVec3::new(2.0, 3.0, 0.0)); + let _ = app.apply_cmd_result(result); + { + let mut host = HostSession::new(&mut app, 1); + assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({token})"), &mut |_| {}).unwrap()); + } + assert!(app.command_line.history.last().unwrap().text.contains("None")); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({token})"), &mut |_| {}).unwrap()); + } + assert!(app.command_line.history.last().unwrap().text.contains("point")); + + use ocs_plugin_api::host::HostNotification; + process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab0, epoch: 101 }).unwrap(); + process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab1, epoch: 202 }).unwrap(); + // Notifications are best-effort and consumed by the runner's reader + // thread before its next Dispatch. Give that thread a bounded handoff. + std::thread::sleep(std::time::Duration::from_millis(50)); + { + let mut host = HostSession::new(&mut app, 1); + assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); + } + let other_events = &app.command_line.history.last().unwrap().text; + assert!(other_events.contains("202") && !other_events.contains("101"), "{other_events}"); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); + } + let first_events = &app.command_line.history.last().unwrap().text; + assert!(first_events.contains("101") && !first_events.contains("202"), "{first_events}"); + + for epoch in 0..257 { + process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab0, epoch }).unwrap(); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); + } + let overflow = &app.command_line.history.last().unwrap().text; + assert!(overflow.contains("overflow") && overflow.contains("dropped"), "{overflow}"); + + process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab0, epoch: 303 }).unwrap(); + process.notify_plugin(None, HostNotification::DocumentTabClosed { tab_id: tab0 }).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + { + let mut host = HostSession::new(&mut app, 0); + assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); + } + assert!(app.command_line.history.last().unwrap().text.contains("[]"), + "closing a tab must discard its queued events"); } #[test] From 0304596cf85d849ddb69da6427eef8a1ead996f1 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 01:58:07 +0200 Subject: [PATCH 06/76] Exercise document model lifecycle through plugin IPC --- docs/plugin-host-model-completion-plan.md | 1 + docs/plugin-host-model-coverage-ledger.md | 2 +- src/app/plugin_host.rs | 8 ++++---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 014422af5..3b9c9db86 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -104,3 +104,4 @@ Start with the **Phase 8 integration harness and coverage ledger**. Re-evaluate - The same Line test now also sends a duplicate-handle batch through Python and confirms the host rejects it without changing geometry or creating an undo entry. - The opt-in `staged_python_point_pick_and_cancel_over_real_ipc` test passes with the same runner. It requests point and entity picks through Python, drives the app's active-command point/object-pick callbacks, polls the returned tokens through a later Python run, and verifies Enter cancellation. The callbacks are invoked by the test harness, not by a physical GUI pointer event; live GUI verification remains open. - The opt-in `staged_python_tabs_isolate_tokens_and_notifications` test passes with two real OCS document tabs and the staged runner. A token created in tab 0 cannot be observed or consumed in tab 1 before or after completion. Host-to-plugin DrawingChanged notifications for both tab ids are delivered over IPC and drained only by their owning tab. A 257-event burst reports overflow, and `DocumentTabClosed` discards queued events for that tab. +- Added `doc.create_entity(kind, **properties)` and `doc.delete_entity(entity_or_handle)` to the high-level Python document model. Unit tests cover forwarding and managed identity fields. The real staged-plugin Line lifecycle now uses these methods rather than low-level `ocs.add`/`remove_entity`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 8369bdd46..c12e1419f 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -7,7 +7,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Kind | Baseline | Gate evidence / blocker | |---|---|---| | Point | Mapped | Host transaction test now checks invalid batch rollback, grouped edit undo, and redo; creation/deletion history, real plugin execution, GUI, and DWG/DXF gates remain pending. | -| Line | Mapped | Actual staged-plugin IPC Python create/edit/delete, three undo entries and undo/redo, and edited DWG/DXF reopen pass in `staged_python_plugin_line_lifecycle_over_real_ipc`. GUI pick/visibility, invalid-input/reference preservation, and document-model create/delete API still pending; not complete. | +| Line | Integration-tested | High-level document-model create/delete and transaction edit pass through the actual staged-plugin IPC runner; three undo entries, undo/redo, duplicate-handle atomic rejection, and edited DWG/DXF reopen pass in `staged_python_plugin_line_lifecycle_over_real_ipc`. GUI pick/visible-geometry observation and broader invalid-value/unmapped-field preservation checks remain pending; not complete. | | Circle | Mapped | Full gate pending. | | Arc | Mapped | Full gate pending. | | Ellipse | Mapped | Existing focused DWG conversion test; full gate pending. | diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 1fe1398b2..70e2f114c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -734,9 +734,9 @@ mod tests { assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); }; dispatch(&mut host, concat!( - "PY_EVAL ocs.add({'kind':'Line',", - "'start':{'x':0.0,'y':0.0,'z':0.0},", - "'end':{'x':1.0,'y':0.0,'z':0.0}})" + "PY_EVAL ocs.active_document.create_entity('Line',", + "start={'x':0.0,'y':0.0,'z':0.0},", + "end={'x':1.0,'y':0.0,'z':0.0}).handle" )); let handles: Vec<_> = host.document().entities().map(|entity| entity.common().handle).collect(); assert_eq!(handles.len(), 1, "Python add did not reach the host"); @@ -761,7 +761,7 @@ mod tests { let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes)) .expect("open edited DXF").read().expect("reopen edited DXF"); assert!(matches!(dxf_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)); - dispatch(&mut host, &format!("PY_EVAL ocs.remove_entity({})", handle.value())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); assert!(host.document().get_entity(handle).is_none(), "Python removal did not reach the host"); drop(process); drop(host); From 33870310383fbbd8ab7908b05b3bde49135c95cf Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 07:33:19 +0200 Subject: [PATCH 07/76] Complete Tolerance host model coverage --- crates/ocs_plugin_api/build.rs | 4 +- .../entity_coverage_policy.json | 6 +- crates/ocs_plugin_api/src/entity_coverage.rs | 75 ++++++++++- docs/plugin-host-model-completion-plan.md | 2 + docs/plugin-host-model-coverage-ledger.md | 6 +- src/app/plugin_host.rs | 122 +++++++++++++++++- 6 files changed, 205 insertions(+), 10 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 782b81cce..82df52245 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -583,7 +583,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { ("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")) { + "column_count" | "row_count" | "column_spacing" | "row_spacing") | + ("Tolerance", "insertion_point" | "direction" | "normal" | "text" | + "dimension_style_name" | "text_height" | "dimension_gap")) { "transaction_geometry" } else { "type_conversion_only" }; properties.push(PropertyCoverage { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index cff46a247..33cdec8a2 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -18,10 +18,12 @@ "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", "insert_point", "x_scale", "y_scale", "z_scale", "rotation", "normal", "column_count", "row_count", "column_spacing", "row_spacing"] + "Insert": ["layer", "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"] }, "readable": { - "Insert": ["block_name"] + "Insert": ["block_name"], + "Tolerance": ["dimension_style_handle"] }, "aliases": { "Polyline": {"closed": "flags"}, diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 3f8131c1d..b76843345 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -79,6 +79,17 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() return Err("Face3D.invisible_edges has unknown bits".into()); } } + EntityType::Tolerance(value) => { + finite_vector("Tolerance.insertion_point", &value.insertion_point)?; + unit_direction("Tolerance.direction", &value.direction)?; + solid_normal("Tolerance.normal", &value.normal)?; + if value.text.trim().is_empty() { return Err("Tolerance.text is empty".into()); } + if value.dimension_style_name.trim().is_empty() { return Err("Tolerance.dimension_style_name is empty".into()); } + if !value.text_height.is_finite() || value.text_height <= 0.0 { + return Err("Tolerance.text_height must be finite and greater than zero".into()); + } + if !value.dimension_gap.is_finite() { return Err("Tolerance.dimension_gap must be finite".into()); } + } _ => {} } Ok(()) @@ -201,11 +212,49 @@ pub fn validate_entity_mutation( return Err("Insert array counts must be greater than zero".into()); } } + (EntityType::Tolerance(old), EntityType::Tolerance(new)) => { + changed3("Tolerance.insertion_point", &old.insertion_point, &new.insertion_point)?; + if !same3(&old.direction, &new.direction) { unit_direction("Tolerance.direction", &new.direction)?; } + if !same3(&old.normal, &new.normal) { solid_normal("Tolerance.normal", &new.normal)?; } + if old.text != new.text && new.text.trim().is_empty() { return Err("Tolerance.text is empty".into()); } + if old.dimension_style_name != new.dimension_style_name && new.dimension_style_name.trim().is_empty() { + return Err("Tolerance.dimension_style_name is empty".into()); + } + if old.text_height.to_bits() != new.text_height.to_bits() + && (!new.text_height.is_finite() || new.text_height <= 0.0) { + return Err("Tolerance.text_height must be finite and greater than zero".into()); + } + if old.dimension_gap.to_bits() != new.dimension_gap.to_bits() && !new.dimension_gap.is_finite() { + return Err("Tolerance.dimension_gap must be finite".into()); + } + if old.dimension_style_handle != new.dimension_style_handle + && !(old.dimension_style_name != new.dimension_style_name && new.dimension_style_handle.is_none()) { + return Err("Tolerance.dimension_style_handle is read-only".into()); + } + } _ => {} } Ok(()) } +/// Validate references which require the surrounding document. Keeping this +/// in the host API gives every scripting adapter the same reference rules. +#[cfg(feature = "host")] +pub fn validate_canvas_entity_references( + document: &crate::host::CadDocument, + entity: &crate::host::EntityType, +) -> Result<(), String> { + use crate::host::EntityType; + if let EntityType::Tolerance(value) = entity { + let found = value.dimension_style_handle.filter(|handle| !handle.is_null()).map_or_else( + || document.dim_styles.iter().any(|style| style.name.eq_ignore_ascii_case(value.dimension_style_name.trim())), + |handle| document.dim_styles.iter().any(|style| style.handle == handle), + ); + if !found { return Err(format!("Tolerance dimension style {:?} does not exist", value.dimension_style_name)); } + } + Ok(()) +} + /// Clone a canvas entity with a new layer, preserving its other snapshot /// fields. Internal records and opaque fallbacks are excluded. #[cfg(feature = "host")] @@ -256,7 +305,7 @@ mod tests { if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(), "Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" | "Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" | - "Ray" | "XLine" | "Solid" | "Face3D" | "Insert") { + "Ray" | "XLine" | "Solid" | "Face3D" | "Insert" | "Tolerance") { assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite || (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind); } @@ -288,9 +337,33 @@ mod tests { "Insert.rotation".to_owned(), "Insert.normal".to_owned(), "Insert.column_count".to_owned(), "Insert.row_count".to_owned(), "Insert.column_spacing".to_owned(), "Insert.row_spacing".to_owned(), + "Tolerance.insertion_point".to_owned(), "Tolerance.direction".to_owned(), + "Tolerance.normal".to_owned(), "Tolerance.text".to_owned(), + "Tolerance.dimension_style_name".to_owned(), "Tolerance.text_height".to_owned(), + "Tolerance.dimension_gap".to_owned(), ])); } + #[cfg(feature = "host")] + #[test] + fn tolerance_geometry_and_style_references_are_validated() { + use crate::host::acadrust::{self, types::Vector3}; + let document = acadrust::CadDocument::new(); + let mut tolerance = acadrust::entities::Tolerance::with_text( + Vector3::new(1.0, 2.0, 0.0), "{\\Fgdt;p}%%v0.1"); + let entity = acadrust::EntityType::Tolerance(tolerance.clone()); + validate_new_canvas_entity(&entity).unwrap(); + validate_canvas_entity_references(&document, &entity).unwrap(); + tolerance.direction = Vector3::new(2.0, 0.0, 0.0); + assert!(validate_new_canvas_entity(&acadrust::EntityType::Tolerance(tolerance.clone())) + .unwrap_err().contains("unit vector")); + tolerance.direction = Vector3::UNIT_X; + tolerance.dimension_style_name = "Missing".into(); + assert!(validate_canvas_entity_references( + &document, &acadrust::EntityType::Tolerance(tolerance)) + .unwrap_err().contains("does not exist")); + } + #[test] fn layer_write_coverage_matches_canvas_scope() { let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 3b9c9db86..b2baffcf2 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -105,3 +105,5 @@ Start with the **Phase 8 integration harness and coverage ledger**. Re-evaluate - The opt-in `staged_python_point_pick_and_cancel_over_real_ipc` test passes with the same runner. It requests point and entity picks through Python, drives the app's active-command point/object-pick callbacks, polls the returned tokens through a later Python run, and verifies Enter cancellation. The callbacks are invoked by the test harness, not by a physical GUI pointer event; live GUI verification remains open. - The opt-in `staged_python_tabs_isolate_tokens_and_notifications` test passes with two real OCS document tabs and the staged runner. A token created in tab 0 cannot be observed or consumed in tab 1 before or after completion. Host-to-plugin DrawingChanged notifications for both tab ids are delivered over IPC and drained only by their owning tab. A 257-event burst reports overflow, and `DocumentTabClosed` discards queued events for that tab. - Added `doc.create_entity(kind, **properties)` and `doc.delete_entity(entity_or_handle)` to the high-level Python document model. Unit tests cover forwarding and managed identity fields. The real staged-plugin Line lifecycle now uses these methods rather than low-level `ocs.add`/`remove_entity`. +- Closed the first new-kind increment for `Tolerance`. The host coverage catalog now maps insertion point, direction, normal, text, dimension style name, text height, and gap; validates geometry and document style references; keeps the style handle read-only; and leaves the undocumented DWG short unmapped. The thin adapter adds generated conversion plus stale style-handle clearing when the style name changes. +- `staged_python_tolerance_lifecycle_over_real_ipc` passes the full C/R/E/D/U/I/W/V/P gate with the actual runner: high-level create/read/transaction edit/delete, live canvas selection, undo/redo, invalid direction and missing style rollback, untouched-field preservation, and DWG/DXF reopen. Tolerance is recorded as **Complete** in the ledger. The queue now continues with `Shape`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index c12e1419f..a770d088d 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,13 +1,13 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). The baseline below is conservative as of 19 September 2026. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **18 of 43 canvas kinds have explicit geometry writes** (17 creatable, plus update-only `Insert`), **1 kind is complete**, and 25 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = portable H7 v5 build. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. | Kind | Baseline | Gate evidence / blocker | |---|---|---| | Point | Mapped | Host transaction test now checks invalid batch rollback, grouped edit undo, and redo; creation/deletion history, real plugin execution, GUI, and DWG/DXF gates remain pending. | -| Line | Integration-tested | High-level document-model create/delete and transaction edit pass through the actual staged-plugin IPC runner; three undo entries, undo/redo, duplicate-handle atomic rejection, and edited DWG/DXF reopen pass in `staged_python_plugin_line_lifecycle_over_real_ipc`. GUI pick/visible-geometry observation and broader invalid-value/unmapped-field preservation checks remain pending; not complete. | +| Line | Integration-tested | High-level document-model create/delete and transaction edit pass through the actual staged-plugin IPC runner; three undo entries, undo/redo, duplicate-handle and nonfinite-coordinate rejection, full typed-entity preservation, Line object-pick routing, and edited DWG/DXF reopen pass. A second editable property outside defining endpoint geometry still needs integration coverage before this row is complete. | | Circle | Mapped | Full gate pending. | | Arc | Mapped | Full gate pending. | | Ellipse | Mapped | Existing focused DWG conversion test; full gate pending. | @@ -23,7 +23,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Solid | Mapped | Focused DWG round trip; full gate pending. | | Face3D | Mapped | Focused DWG round trip; full gate pending. | | Insert | Update-only | Transform transaction and undo tested; Python creation, deletion, real IPC/GUI and DWG/DXF gates pending. | -| Tolerance | Layer-only | Next new kind after integration harness. | +| Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | | Shape | Layer-only | Requires valid shape/style fixture. | | AttributeDefinition | Layer-only | Requires block-definition fixture. | | AttributeEntity | Layer-only | Requires linked Insert and attribute-sequence fixture. | diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 70e2f114c..3a73ce3ba 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -154,6 +154,9 @@ impl<'a> HostSession<'a> { } ocs_plugin_api::entity_coverage::validate_entity_mutation(existing, entity) .map_err(|error| format!("entity {handle:?}: {error}"))?; + ocs_plugin_api::entity_coverage::validate_canvas_entity_references( + self.document(), entity, + ).map_err(|error| format!("entity {handle:?}: {error}"))?; if self.app.tabs[self.tab].scene.is_layer_locked(handle) { return Err(format!("entity {handle:?} is on a locked layer")); } @@ -641,7 +644,7 @@ fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::comm mod tests { use super::*; use crate::app::OpenCADStudio; - use acadrust::entities::Point; + use acadrust::entities::{Line, Point}; use acadrust::xdata::XDataValue; use ocs_plugin_api::host::DocumentReader; @@ -742,11 +745,23 @@ mod tests { assert_eq!(handles.len(), 1, "Python add did not reach the host"); let handle = handles[0]; assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0)); + let before_edit = host.document().get_entity(handle).unwrap().clone(); dispatch(&mut host, &format!( "PY_EVAL ocs.update_many('Move line', [{{'handle':{},'end':{{'x':5.0,'y':0.0,'z':0.0}}}}])", handle.value() )); - assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)); + let mut expected_after_edit = before_edit; + let EntityType::Line(expected_line) = &mut expected_after_edit else { unreachable!() }; + expected_line.end.x = 5.0; + assert_eq!(host.document().get_entity(handle), Some(&expected_after_edit), + "partial Python edit must preserve every unmentioned common and geometry field"); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject NaN', [{{'handle':{},'start':{{'x':float('nan'),'y':0.0,'z':0.0}}}}])", + handle.value() + )); + assert_eq!(host.document().get_entity(handle), Some(&expected_after_edit), + "invalid geometry changed the line"); + assert!(host.app.command_line.history.last().unwrap().text.contains("finite coordinates")); dispatch(&mut host, &format!( "PY_EVAL ocs.update_many('Reject duplicate', [{{'handle':{},'end':{{'x':8.0,'y':0.0,'z':0.0}}}},{{'handle':{},'end':{{'x':9.0,'y':0.0,'z':0.0}}}}])", handle.value(), handle.value() @@ -778,6 +793,104 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } + #[test] + fn staged_python_tolerance_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, + crate::plugin::v4_support::notification_handler(), + ).expect("spawn staged Python plugin"); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + dispatch(&mut host, concat!( + "PY_EVAL ocs.active_document.create_entity('Tolerance',", + "insertion_point={'x':1.0,'y':2.0,'z':0.0},", + "text='POSITION%%v0.1').handle" + )); + let handle = host.document().entities().next().expect("Python created a Tolerance") + .common().handle; + let Some(EntityType::Tolerance(created)) = host.document().get_entity(handle) else { + panic!("expected Tolerance"); + }; + assert_eq!(created.insertion_point, acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + assert_eq!(created.dimension_style_name, "Standard"); + let created = created.clone(); + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.entities[{}].text", handle.value())); + assert!(host.app.command_line.history.last().unwrap().text.contains("POSITION%%v0.1")); + + let script_path = std::env::temp_dir().join(format!( + "ocs_tolerance_document_model_{}.py", std::process::id())); + std::fs::write(&script_path, format!(concat!( + "doc = ocs.active_document\n", + "tolerance = doc.entities[{}]\n", + "with doc.transaction('Edit tolerance'):\n", + " tolerance.insertion_point = (4.0, 5.0, 0.0)\n", + " tolerance.direction = (0.0, 1.0, 0.0)\n", + " tolerance.text = 'POSITION%%v0.2'\n", + "doc.selection = [tolerance]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script_path.display())); + let _ = std::fs::remove_file(&script_path); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::Tolerance(edited) = &expected else { unreachable!() }; + assert_eq!(edited.insertion_point, acadrust::types::Vector3::new(4.0, 5.0, 0.0)); + assert_eq!(edited.direction, acadrust::types::Vector3::UNIT_Y); + assert_eq!(edited.text, "POSITION%%v0.2"); + assert_eq!(edited.common, created.common); + assert_eq!(edited.normal, created.normal); + assert_eq!(edited.dimension_style_name, created.dimension_style_name); + assert_eq!(edited.dimension_style_handle, created.dimension_style_handle); + assert_eq!(edited.text_height, created.text_height); + assert_eq!(edited.dimension_gap, created.dimension_gap); + assert_eq!(edited.dwg_unknown_short, created.dwg_unknown_short); + assert_eq!(host.selection(), vec![handle], "Python selection did not reach the canvas"); + + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject direction', [{{'handle':{},'direction':{{'x':2.0,'y':0.0,'z':0.0}}}}])", + handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + assert!(host.app.command_line.history.last().unwrap().text.contains("unit vector")); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'dimension_style_name':'Missing'}}])", + handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + assert!(host.app.command_line.history.last().unwrap().text.contains("does not exist")); + + let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes)).read().unwrap(); + assert!(matches!(dwg_doc.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2")); + let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap(); + let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes)) + .unwrap().read().unwrap(); + assert!(matches!(dxf_doc.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2")); + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 1.0 && value.text == "POSITION%%v0.1")); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; @@ -823,7 +936,10 @@ mod tests { let target = { let mut host = HostSession::new(&mut app, 0); - host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(6.0, 7.0, 0.0)))) + host.add_entity(EntityType::Line(Line::from_points( + acadrust::types::Vector3::new(6.0, 7.0, 0.0), + acadrust::types::Vector3::new(9.0, 7.0, 0.0), + ))) }; let entity_token = request(&mut app, "Pick an entity", true); assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick()); From 54322e00e3bde29b1f34872a6fe747257f3efb13 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 07:43:30 +0200 Subject: [PATCH 08/76] Complete Shape host model coverage --- crates/ocs_plugin_api/build.rs | 5 +- .../entity_coverage_policy.json | 6 +- crates/ocs_plugin_api/src/entity_coverage.rs | 111 +++++++++++- docs/plugin-host-model-completion-plan.md | 2 + docs/plugin-host-model-coverage-ledger.md | 4 +- src/app/plugin_host.rs | 164 ++++++++++++++++-- src/entities/shape.rs | 2 +- 7 files changed, 271 insertions(+), 23 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 82df52245..388c31471 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -585,7 +585,10 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { ("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")) { + "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")) { "transaction_geometry" } else { "type_conversion_only" }; properties.push(PropertyCoverage { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 33cdec8a2..f0d4fb537 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -19,11 +19,13 @@ "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", "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"] + "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"] }, "readable": { "Insert": ["block_name"], - "Tolerance": ["dimension_style_handle"] + "Tolerance": ["dimension_style_handle"], + "Shape": ["style_handle"] }, "aliases": { "Polyline": {"closed": "flags"}, diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index b76843345..fc591e7dd 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -90,6 +90,22 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } if !value.dimension_gap.is_finite() { return Err("Tolerance.dimension_gap must be finite".into()); } } + EntityType::Shape(value) => { + finite_vector("Shape.insertion_point", &value.insertion_point)?; + solid_normal("Shape.normal", &value.normal)?; + if !value.size.is_finite() || value.size <= 0.0 { return Err("Shape.size must be finite and greater than zero".into()); } + if value.shape_name.trim().is_empty() { return Err("Shape.shape_name is empty".into()); } + if !(1..=i16::MAX as i32).contains(&value.shape_number) { return Err("Shape.shape_number must be between 1 and 32767".into()); } + if !value.rotation.is_finite() { return Err("Shape.rotation must be finite".into()); } + if !value.relative_x_scale.is_finite() || value.relative_x_scale.abs() < 1e-12 { + return Err("Shape.relative_x_scale must be finite and nonzero".into()); + } + if !value.oblique_angle.is_finite() || value.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 { + return Err("Shape.oblique_angle must be finite and between -PI/2 and PI/2".into()); + } + if !value.thickness.is_finite() { return Err("Shape.thickness must be finite".into()); } + if value.style_name.trim().is_empty() { return Err("Shape.style_name is empty".into()); } + } _ => {} } Ok(()) @@ -228,10 +244,38 @@ pub fn validate_entity_mutation( return Err("Tolerance.dimension_gap must be finite".into()); } if old.dimension_style_handle != new.dimension_style_handle - && !(old.dimension_style_name != new.dimension_style_name && new.dimension_style_handle.is_none()) { + && old.dimension_style_name == new.dimension_style_name { return Err("Tolerance.dimension_style_handle is read-only".into()); } } + (EntityType::Shape(old), EntityType::Shape(new)) => { + changed3("Shape.insertion_point", &old.insertion_point, &new.insertion_point)?; + if !same3(&old.normal, &new.normal) { solid_normal("Shape.normal", &new.normal)?; } + if old.size.to_bits() != new.size.to_bits() && (!new.size.is_finite() || new.size <= 0.0) { + return Err("Shape.size must be finite and greater than zero".into()); + } + if old.shape_name != new.shape_name && new.shape_name.trim().is_empty() { return Err("Shape.shape_name is empty".into()); } + if old.shape_number != new.shape_number && !(1..=i16::MAX as i32).contains(&new.shape_number) { + return Err("Shape.shape_number must be between 1 and 32767".into()); + } + if old.rotation.to_bits() != new.rotation.to_bits() && !new.rotation.is_finite() { return Err("Shape.rotation must be finite".into()); } + if old.relative_x_scale.to_bits() != new.relative_x_scale.to_bits() + && (!new.relative_x_scale.is_finite() || new.relative_x_scale.abs() < 1e-12) { + return Err("Shape.relative_x_scale must be finite and nonzero".into()); + } + if old.oblique_angle.to_bits() != new.oblique_angle.to_bits() + && (!new.oblique_angle.is_finite() || new.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2) { + return Err("Shape.oblique_angle must be finite and between -PI/2 and PI/2".into()); + } + if old.thickness.to_bits() != new.thickness.to_bits() && !new.thickness.is_finite() { + return Err("Shape.thickness must be finite".into()); + } + if old.style_name != new.style_name && new.style_name.trim().is_empty() { return Err("Shape.style_name is empty".into()); } + if old.style_handle != new.style_handle + && !(old.style_name != new.style_name && new.style_handle.is_some()) { + return Err("Shape.style_handle is read-only".into()); + } + } _ => {} } Ok(()) @@ -252,9 +296,42 @@ pub fn validate_canvas_entity_references( ); if !found { return Err(format!("Tolerance dimension style {:?} does not exist", value.dimension_style_name)); } } + if let EntityType::Shape(value) = entity { + let style = value.style_handle.filter(|handle| !handle.is_null()) + .and_then(|handle| document.text_styles.iter().find(|style| style.handle == handle)) + .or_else(|| document.text_styles.iter().find(|style| style.name.eq_ignore_ascii_case(value.style_name.trim()))) + .ok_or_else(|| format!("Shape text style {:?} does not exist", value.style_name))?; + if !style.is_shape_file { return Err(format!("Shape text style {:?} is not a shape-file style", style.name)); } + if style.font_file.trim().is_empty() { return Err(format!("Shape text style {:?} has no SHX file", style.name)); } + } Ok(()) } +/// Fill name-based table references with stable handles before a new entity or +/// replacement is committed. Existing non-null handles remain authoritative +/// for legacy DWG entities whose fallback name can be stale. +#[cfg(feature = "host")] +pub fn bind_canvas_entity_references( + document: &crate::host::CadDocument, + entity: &mut crate::host::EntityType, +) -> Result<(), String> { + use crate::host::EntityType; + match entity { + EntityType::Tolerance(value) if value.dimension_style_handle.filter(|h| !h.is_null()).is_none() => { + value.dimension_style_handle = document.dim_styles.iter() + .find(|style| style.name.eq_ignore_ascii_case(value.dimension_style_name.trim())) + .map(|style| style.handle); + } + EntityType::Shape(value) if value.style_handle.filter(|h| !h.is_null()).is_none() => { + value.style_handle = document.text_styles.iter() + .find(|style| style.name.eq_ignore_ascii_case(value.style_name.trim())) + .map(|style| style.handle); + } + _ => {} + } + validate_canvas_entity_references(document, entity) +} + /// Clone a canvas entity with a new layer, preserving its other snapshot /// fields. Internal records and opaque fallbacks are excluded. #[cfg(feature = "host")] @@ -305,7 +382,7 @@ mod tests { if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(), "Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" | "Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" | - "Ray" | "XLine" | "Solid" | "Face3D" | "Insert" | "Tolerance") { + "Ray" | "XLine" | "Solid" | "Face3D" | "Insert" | "Tolerance" | "Shape") { assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite || (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind); } @@ -341,6 +418,11 @@ mod tests { "Tolerance.normal".to_owned(), "Tolerance.text".to_owned(), "Tolerance.dimension_style_name".to_owned(), "Tolerance.text_height".to_owned(), "Tolerance.dimension_gap".to_owned(), + "Shape.insertion_point".to_owned(), "Shape.size".to_owned(), + "Shape.shape_name".to_owned(), "Shape.shape_number".to_owned(), + "Shape.rotation".to_owned(), "Shape.relative_x_scale".to_owned(), + "Shape.oblique_angle".to_owned(), "Shape.normal".to_owned(), + "Shape.thickness".to_owned(), "Shape.style_name".to_owned(), ])); } @@ -364,6 +446,31 @@ mod tests { .unwrap_err().contains("does not exist")); } + #[cfg(feature = "host")] + #[test] + fn shape_geometry_and_shape_file_style_are_validated_and_bound() { + use crate::host::acadrust::{self, tables::TableEntry, types::Vector3}; + let mut document = acadrust::CadDocument::new(); + let mut style = acadrust::tables::TextStyle::new("Symbols"); + style.set_handle(document.allocate_handle()); + style.is_shape_file = true; + style.font_file = "symbols.shx".into(); + let style_handle = style.handle; + document.text_styles.add(style).unwrap(); + let mut shape = acadrust::entities::Shape::with_style( + Vector3::new(1.0, 2.0, 0.0), "ARROW", "Symbols", 2.0, 0.5); + shape.shape_number = 1; + let mut entity = acadrust::EntityType::Shape(shape); + validate_new_canvas_entity(&entity).unwrap(); + bind_canvas_entity_references(&document, &mut entity).unwrap(); + assert!(matches!(entity, acadrust::EntityType::Shape(ref value) + if value.style_handle == Some(style_handle))); + let acadrust::EntityType::Shape(mut bad) = entity else { unreachable!() }; + bad.size = 0.0; + assert!(validate_new_canvas_entity(&acadrust::EntityType::Shape(bad)) + .unwrap_err().contains("greater than zero")); + } + #[test] fn layer_write_coverage_matches_canvas_scope() { let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index b2baffcf2..310050382 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -107,3 +107,5 @@ Start with the **Phase 8 integration harness and coverage ledger**. Re-evaluate - Added `doc.create_entity(kind, **properties)` and `doc.delete_entity(entity_or_handle)` to the high-level Python document model. Unit tests cover forwarding and managed identity fields. The real staged-plugin Line lifecycle now uses these methods rather than low-level `ocs.add`/`remove_entity`. - Closed the first new-kind increment for `Tolerance`. The host coverage catalog now maps insertion point, direction, normal, text, dimension style name, text height, and gap; validates geometry and document style references; keeps the style handle read-only; and leaves the undocumented DWG short unmapped. The thin adapter adds generated conversion plus stale style-handle clearing when the style name changes. - `staged_python_tolerance_lifecycle_over_real_ipc` passes the full C/R/E/D/U/I/W/V/P gate with the actual runner: high-level create/read/transaction edit/delete, live canvas selection, undo/redo, invalid direction and missing style rollback, untouched-field preservation, and DWG/DXF reopen. Tolerance is recorded as **Complete** in the ledger. The queue now continues with `Shape`. +- Closed `Shape` with a generated SHX fixture and a real shape-file `TextStyle`. Host-owned binding resolves the Python style name to the stable table handle required by DWG; validation rejects missing/non-shape styles and invalid geometry. The adapter maps all stable shape fields and keeps the bound style handle read-only. +- `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the actual runner, checks the real SHX linework before and after DWG/DXF reopen, and proves that invalid size/style edits remain atomic. The DXF renderer fallback now searches named `is_shape_file` styles as well as legacy unnamed styles. Shape is **Complete**; the queue now continues with `AttributeDefinition`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index a770d088d..fd99623aa 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **18 of 43 canvas kinds have explicit geometry writes** (17 creatable, plus update-only `Insert`), **1 kind is complete**, and 25 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **19 of 43 canvas kinds have explicit geometry writes** (18 creatable, plus update-only `Insert`), **2 kinds are complete**, and 24 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = portable H7 v5 build. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -24,7 +24,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Face3D | Mapped | Focused DWG round trip; full gate pending. | | Insert | Update-only | Transform transaction and undo tested; Python creation, deletion, real IPC/GUI and DWG/DXF gates pending. | | Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | -| Shape | Layer-only | Requires valid shape/style fixture. | +| Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | | AttributeDefinition | Layer-only | Requires block-definition fixture. | | AttributeEntity | Layer-only | Requires linked Insert and attribute-sequence fixture. | | Hatch | Layer-only | Requires valid boundary and pattern fixtures. | diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 3a73ce3ba..3b481586c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -132,13 +132,13 @@ impl<'a> HostSession<'a> { pub fn update_entities_transaction( &mut self, label: &str, - entities: Vec, + mut entities: Vec, ) -> Result<(), String> { use std::collections::HashSet; if label.trim().is_empty() { return Err("transaction label is empty".into()); } if entities.is_empty() { return Ok(()); } let mut seen = HashSet::new(); - for entity in &entities { + for entity in &mut entities { let handle = entity.common().handle; if handle.is_null() || !seen.insert(handle) { return Err(format!("null or duplicate entity handle: {handle:?}")); @@ -152,11 +152,11 @@ impl<'a> HostSession<'a> { if existing.common().owner_handle != entity.common().owner_handle { return Err(format!("entity {handle:?} changes owner")); } - ocs_plugin_api::entity_coverage::validate_entity_mutation(existing, entity) - .map_err(|error| format!("entity {handle:?}: {error}"))?; - ocs_plugin_api::entity_coverage::validate_canvas_entity_references( + ocs_plugin_api::entity_coverage::bind_canvas_entity_references( self.document(), entity, ).map_err(|error| format!("entity {handle:?}: {error}"))?; + ocs_plugin_api::entity_coverage::validate_entity_mutation(existing, entity) + .map_err(|error| format!("entity {handle:?}: {error}"))?; if self.app.tabs[self.tab].scene.is_layer_locked(handle) { return Err(format!("entity {handle:?} is on a locked layer")); } @@ -644,6 +644,7 @@ fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::comm mod tests { use super::*; use crate::app::OpenCADStudio; + use crate::entities::traits::RenderConvertible; use acadrust::entities::{Line, Point}; use acadrust::xdata::XDataValue; use ocs_plugin_api::host::DocumentReader; @@ -891,6 +892,133 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } + #[test] + fn staged_python_shape_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + + let shx_path = std::env::temp_dir().join(format!("ocs_host_shape_{}.shx", std::process::id())); + let mut shx = b"AutoCAD-86 shapes 1.0\r\n\x1A".to_vec(); + for value in [1_u16, 1, 1, 1, 9] { shx.extend_from_slice(&value.to_le_bytes()); } + shx.extend_from_slice(b"ARROW\0\x10\x14\0"); + std::fs::write(&shx_path, shx).unwrap(); + + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let style_handle = host.document_mut().allocate_handle(); + let mut style = acadrust::tables::TextStyle::new("TestShapes"); + style.handle = style_handle; + style.is_shape_file = true; + style.font_file = shx_path.to_string_lossy().into_owned(); + host.document_mut().text_styles.add(style).unwrap(); + + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, + crate::plugin::v4_support::notification_handler(), + ).expect("spawn staged Python plugin"); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + dispatch(&mut host, concat!( + "PY_EVAL ocs.active_document.create_entity('Shape',", + "insertion_point={'x':1.0,'y':2.0,'z':0.0},size=2.0,", + "shape_name='ARROW',shape_number=1,style_name='TestShapes').handle" + )); + let handle = host.document().entities().next().expect("Python created a Shape") + .common().handle; + let Some(EntityType::Shape(created)) = host.document().get_entity(handle) else { + panic!("expected Shape"); + }; + assert_eq!(created.style_handle, Some(style_handle)); + let created = created.clone(); + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.entities[{}].shape_name", handle.value())); + assert!(host.app.command_line.history.last().unwrap().text.contains("ARROW")); + + let script_path = std::env::temp_dir().join(format!( + "ocs_shape_document_model_{}.py", std::process::id())); + std::fs::write(&script_path, format!(concat!( + "doc = ocs.active_document\n", + "shape = doc.entities[{}]\n", + "with doc.transaction('Edit shape'):\n", + " shape.insertion_point = (4.0, 5.0, 0.0)\n", + " shape.size = 3.0\n", + " shape.rotation = 0.5\n", + " shape.relative_x_scale = 1.5\n", + "doc.selection = [shape]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script_path.display())); + let _ = std::fs::remove_file(&script_path); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::Shape(edited) = &expected else { unreachable!() }; + assert_eq!(edited.insertion_point, acadrust::types::Vector3::new(4.0, 5.0, 0.0)); + assert_eq!(edited.size, 3.0); + assert_eq!(edited.rotation, 0.5); + assert_eq!(edited.relative_x_scale, 1.5); + assert_eq!(edited.common, created.common); + assert_eq!(edited.shape_name, created.shape_name); + assert_eq!(edited.shape_number, created.shape_number); + assert_eq!(edited.style_name, created.style_name); + assert_eq!(edited.style_handle, created.style_handle); + assert_eq!(edited.normal, created.normal); + assert_eq!(edited.thickness, created.thickness); + assert_eq!(host.selection(), vec![handle]); + + let assert_real_glyph = |entity: &EntityType, document: &CadDocument| { + let EntityType::Shape(shape) = entity else { panic!("expected Shape"); }; + let render = shape.to_render(document).expect("shape render"); + let crate::scene::convert::acad_to_render::RenderObject::Lines(points) = render.object else { + panic!("expected shape linework"); + }; + assert_eq!(points.len(), 3, "expected SHX glyph rather than diamond placeholder"); + assert!(points.iter().flatten().all(|value| value.is_finite())); + }; + assert_real_glyph(&expected, host.document()); + + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject size', [{{'handle':{},'size':0.0}}])", + handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'style_name':'Missing'}}])", + handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + assert!(host.app.command_line.history.last().unwrap().text.contains("does not exist")); + + let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + let dwg_doc = crate::io::load_bytes("shape.dwg", dwg_bytes).unwrap(); + let dwg_entity = dwg_doc.get_entity(handle).expect("Shape survives DWG"); + assert!(matches!(dwg_entity, EntityType::Shape(value) + if value.shape_number == 1 && value.size == 3.0 && value.rotation == 0.5)); + assert_real_glyph(dwg_entity, &dwg_doc); + let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap(); + let dxf_doc = crate::io::load_bytes("shape.dxf", dxf_bytes).unwrap(); + let dxf_entity = dxf_doc.get_entity(handle).expect("Shape survives DXF"); + assert!(matches!(dxf_entity, EntityType::Shape(value) + if value.shape_name == "ARROW" && value.size == 3.0 && (value.rotation - 0.5).abs() < 1e-12)); + assert_real_glyph(dxf_entity, &dxf_doc); + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + drop(process); + drop(host); + let _ = std::fs::remove_file(&shx_path); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Shape(value)) + if value.insertion_point.x == 1.0 && value.size == 2.0)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; @@ -1019,17 +1147,23 @@ mod tests { // Notifications are best-effort and consumed by the runner's reader // thread before its next Dispatch. Give that thread a bounded handoff. std::thread::sleep(std::time::Duration::from_millis(50)); - { - let mut host = HostSession::new(&mut app, 1); - assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); - } - let other_events = &app.command_line.history.last().unwrap().text; + let poll_events_until = |app: &mut OpenCADStudio, tab: usize, epoch: &str| { + let mut output = String::new(); + for _ in 0..10 { + { + let mut host = HostSession::new(app, tab); + assert!(process.dispatch(&mut host, + "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); + } + output = app.command_line.history.last().unwrap().text.clone(); + if output.contains(epoch) { break; } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + output + }; + let other_events = poll_events_until(&mut app, 1, "202"); assert!(other_events.contains("202") && !other_events.contains("101"), "{other_events}"); - { - let mut host = HostSession::new(&mut app, 0); - assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); - } - let first_events = &app.command_line.history.last().unwrap().text; + let first_events = poll_events_until(&mut app, 0, "101"); assert!(first_events.contains("101") && !first_events.contains("202"), "{first_events}"); for epoch in 0..257 { diff --git a/src/entities/shape.rs b/src/entities/shape.rs index 7b4b1b8f1..12cb0f4cc 100644 --- a/src/entities/shape.rs +++ b/src/entities/shape.rs @@ -119,7 +119,7 @@ fn shx_polylines( // glyph named like this shape. if !name.is_empty() { for style in document.text_styles.iter() { - if style.name.is_empty() && !style.font_file.trim().is_empty() { + if style.is_shape_file && !style.font_file.trim().is_empty() { if let Some(p) = try_style(style) { return Some(p); } From 5b854bd121b18573e4d2e41bd60dfb4d17d1ab16 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 12:47:48 +0200 Subject: [PATCH 09/76] Add AttributeDefinition host model support --- crates/ocs_plugin_api/build.rs | 9 +- .../entity_coverage_policy.json | 3 +- crates/ocs_plugin_api/src/entity_coverage.rs | 145 ++++++++++++++- src/app/plugin_host.rs | 168 ++++++++++++++++++ src/io/mod.rs | 13 +- 5 files changed, 326 insertions(+), 12 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 388c31471..83eff3320 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -545,6 +545,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { 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) @@ -588,7 +589,13 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { "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")) { + "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")) { "transaction_geometry" } else { "type_conversion_only" }; properties.push(PropertyCoverage { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index f0d4fb537..e4f372ee4 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -20,7 +20,8 @@ "Face3D": ["layer", "first_corner", "second_corner", "third_corner", "fourth_corner", "invisible_edges"], "Insert": ["layer", "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"] + "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"] }, "readable": { "Insert": ["block_name"], diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index fc591e7dd..17a52851b 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -106,6 +106,25 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() if !value.thickness.is_finite() { return Err("Shape.thickness must be finite".into()); } if value.style_name.trim().is_empty() { return Err("Shape.style_name is empty".into()); } } + EntityType::AttributeDefinition(value) => { + finite_vector("AttributeDefinition.insertion_point", &value.insertion_point)?; + finite_vector("AttributeDefinition.alignment_point", &value.alignment_point)?; + solid_normal("AttributeDefinition.normal", &value.normal)?; + if value.tag.trim().is_empty() || value.tag.chars().any(char::is_whitespace) { + return Err("AttributeDefinition.tag must be nonempty and contain no whitespace".into()); + } + if !value.height.is_finite() || value.height <= 0.0 { return Err("AttributeDefinition.height must be finite and greater than zero".into()); } + if !value.rotation.is_finite() { return Err("AttributeDefinition.rotation must be finite".into()); } + if !value.width_factor.is_finite() || value.width_factor.abs() < 1e-12 { + return Err("AttributeDefinition.width_factor must be finite and nonzero".into()); + } + if !value.oblique_angle.is_finite() || value.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 { + return Err("AttributeDefinition.oblique_angle must be finite and between -PI/2 and PI/2".into()); + } + if value.text_style.trim().is_empty() { return Err("AttributeDefinition.text_style is empty".into()); } + if value.field_length < 0 { return Err("AttributeDefinition.field_length must be nonnegative".into()); } + if value.line_count < 1 { return Err("AttributeDefinition.line_count must be greater than zero".into()); } + } _ => {} } Ok(()) @@ -276,6 +295,45 @@ pub fn validate_entity_mutation( return Err("Shape.style_handle is read-only".into()); } } + (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { + changed3("AttributeDefinition.insertion_point", &old.insertion_point, &new.insertion_point)?; + changed3("AttributeDefinition.alignment_point", &old.alignment_point, &new.alignment_point)?; + if !same3(&old.normal, &new.normal) { solid_normal("AttributeDefinition.normal", &new.normal)?; } + if old.tag != new.tag && (new.tag.trim().is_empty() || new.tag.chars().any(char::is_whitespace)) { + return Err("AttributeDefinition.tag must be nonempty and contain no whitespace".into()); + } + if old.height.to_bits() != new.height.to_bits() && (!new.height.is_finite() || new.height <= 0.0) { + return Err("AttributeDefinition.height must be finite and greater than zero".into()); + } + for (name, before, after) in [ + ("rotation", old.rotation, new.rotation), + ("oblique_angle", old.oblique_angle, new.oblique_angle), + ] { + if before.to_bits() != after.to_bits() && !after.is_finite() { + return Err(format!("AttributeDefinition.{name} must be finite")); + } + } + if old.width_factor.to_bits() != new.width_factor.to_bits() + && (!new.width_factor.is_finite() || new.width_factor.abs() < 1e-12) { + return Err("AttributeDefinition.width_factor must be finite and nonzero".into()); + } + if old.oblique_angle.to_bits() != new.oblique_angle.to_bits() + && new.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 { + return Err("AttributeDefinition.oblique_angle must be between -PI/2 and PI/2".into()); + } + if old.text_style != new.text_style && new.text_style.trim().is_empty() { + return Err("AttributeDefinition.text_style is empty".into()); + } + if old.field_length != new.field_length && new.field_length < 0 { + return Err("AttributeDefinition.field_length must be nonnegative".into()); + } + if old.line_count != new.line_count && new.line_count < 1 { + return Err("AttributeDefinition.line_count must be greater than zero".into()); + } + if old.embedded_mtext != new.embedded_mtext { + return Err("AttributeDefinition.embedded_mtext is unmapped and read-only".into()); + } + } _ => {} } Ok(()) @@ -289,6 +347,15 @@ pub fn validate_canvas_entity_references( entity: &crate::host::EntityType, ) -> Result<(), String> { use crate::host::EntityType; + let owner = entity.common().owner_handle; + if !owner.is_null() { + let block_owner = document.block_records.iter().any(|record| record.handle == owner); + let insert_owner = matches!(entity, EntityType::AttributeEntity(_)) + && matches!(document.get_entity(owner), Some(EntityType::Insert(_))); + if !block_owner && !insert_owner { + return Err(format!("entity owner {owner:?} does not exist")); + } + } if let EntityType::Tolerance(value) = entity { let found = value.dimension_style_handle.filter(|handle| !handle.is_null()).map_or_else( || document.dim_styles.iter().any(|style| style.name.eq_ignore_ascii_case(value.dimension_style_name.trim())), @@ -304,6 +371,19 @@ pub fn validate_canvas_entity_references( if !style.is_shape_file { return Err(format!("Shape text style {:?} is not a shape-file style", style.name)); } if style.font_file.trim().is_empty() { return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::AttributeDefinition(value) = entity { + let owner = entity.common().owner_handle; + let block = document.block_records.iter().find(|record| record.handle == owner) + .ok_or_else(|| "AttributeDefinition requires an existing block-record owner".to_owned())?; + if block.is_model_space() || block.is_paper_space() { + return Err("AttributeDefinition owner must be a block definition".into()); + } + let style = document.text_styles.get(value.text_style.trim()) + .ok_or_else(|| format!("AttributeDefinition text style {:?} does not exist", value.text_style))?; + if style.is_shape_file { + return Err(format!("AttributeDefinition text style {:?} is a shape-file style", style.name)); + } + } Ok(()) } @@ -364,6 +444,7 @@ mod tests { assert!(variants.iter().any(|v| v.name == entry.kind)); assert!(entry.properties.iter().any(|p| p.name == "handle" && p.model_access == ModelAccess::ReadOnly)); assert!(entry.properties.iter().any(|p| p.name == "kind" && p.model_access == ModelAccess::ReadOnly)); + assert!(entry.properties.iter().any(|p| p.name == "owner_handle" && p.model_access == ModelAccess::ReadOnly)); let mut source_paths = std::collections::HashSet::new(); for property in &entry.properties { assert!(source_paths.insert(&property.source_path), "duplicate source path in {}: {}", entry.kind, property.source_path); @@ -382,7 +463,8 @@ mod tests { if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(), "Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" | "Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" | - "Ray" | "XLine" | "Solid" | "Face3D" | "Insert" | "Tolerance" | "Shape") { + "Ray" | "XLine" | "Solid" | "Face3D" | "Insert" | "Tolerance" | "Shape" | + "AttributeDefinition") { assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite || (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind); } @@ -423,6 +505,21 @@ mod tests { "Shape.rotation".to_owned(), "Shape.relative_x_scale".to_owned(), "Shape.oblique_angle".to_owned(), "Shape.normal".to_owned(), "Shape.thickness".to_owned(), "Shape.style_name".to_owned(), + "AttributeDefinition.tag".to_owned(), "AttributeDefinition.prompt".to_owned(), + "AttributeDefinition.default_value".to_owned(), + "AttributeDefinition.insertion_point".to_owned(), + "AttributeDefinition.alignment_point".to_owned(), + "AttributeDefinition.height".to_owned(), "AttributeDefinition.rotation".to_owned(), + "AttributeDefinition.width_factor".to_owned(), + "AttributeDefinition.oblique_angle".to_owned(), + "AttributeDefinition.text_style".to_owned(), + "AttributeDefinition.text_generation_flags".to_owned(), + "AttributeDefinition.horizontal_alignment".to_owned(), + "AttributeDefinition.vertical_alignment".to_owned(), + "AttributeDefinition.flags".to_owned(), "AttributeDefinition.field_length".to_owned(), + "AttributeDefinition.normal".to_owned(), "AttributeDefinition.mtext_flag".to_owned(), + "AttributeDefinition.is_multiline".to_owned(), "AttributeDefinition.line_count".to_owned(), + "AttributeDefinition.lock_position".to_owned(), ])); } @@ -593,4 +690,50 @@ mod tests { assert_eq!(patched, expected); assert!(patch_canvas_layer(&hatch, " ").is_err()); } + + #[cfg(feature = "host")] + #[test] + fn attribute_definition_requires_a_block_owner_and_text_style() { + use crate::host::acadrust::{self, entities::{Block, BlockEnd}, types::{Handle, Vector3}}; + let mut document = acadrust::CadDocument::new(); + let next = document.next_handle(); + let record_handle = Handle::new(next); + let block_handle = Handle::new(next + 1); + let end_handle = Handle::new(next + 2); + let mut record = acadrust::tables::BlockRecord::new("TAGBLOCK"); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = end_handle; + document.block_records.add(record).unwrap(); + let mut block = Block::new("TAGBLOCK", Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + document.add_entity(acadrust::EntityType::Block(block)).unwrap(); + let mut end = BlockEnd::new(); + end.common.handle = end_handle; + end.common.owner_handle = record_handle; + document.add_entity(acadrust::EntityType::BlockEnd(end)).unwrap(); + + let mut definition = acadrust::entities::AttributeDefinition::new( + "PART_NO".into(), "Part number".into(), "PN-001".into()); + definition.common.owner_handle = record_handle; + definition.insertion_point = Vector3::new(1.0, 2.0, 0.0); + let entity = acadrust::EntityType::AttributeDefinition(definition.clone()); + validate_new_canvas_entity(&entity).unwrap(); + validate_canvas_entity_references(&document, &entity).unwrap(); + + definition.common.owner_handle = Handle::NULL; + assert!(validate_canvas_entity_references( + &document, &acadrust::EntityType::AttributeDefinition(definition.clone())) + .unwrap_err().contains("block-record owner")); + definition.common.owner_handle = record_handle; + definition.text_style = "Missing".into(); + assert!(validate_canvas_entity_references( + &document, &acadrust::EntityType::AttributeDefinition(definition.clone())) + .unwrap_err().contains("does not exist")); + definition.text_style = "Standard".into(); + definition.tag = "BAD TAG".into(); + assert!(validate_new_canvas_entity(&acadrust::EntityType::AttributeDefinition(definition)) + .unwrap_err().contains("no whitespace")); + } } diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 3b481586c..17984346f 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -1019,6 +1019,174 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } + #[test] + fn staged_python_attribute_definition_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let next = host.document().next_handle(); + let block_record_handle = Handle::new(next); + let block_handle = Handle::new(next + 1); + let block_end_handle = Handle::new(next + 2); + let mut block_record = acadrust::tables::BlockRecord::new("TAGBLOCK"); + block_record.handle = block_record_handle; + block_record.block_entity_handle = block_handle; + block_record.block_end_handle = block_end_handle; + host.document_mut().block_records.add(block_record).unwrap(); + let mut block = acadrust::entities::Block::new( + "TAGBLOCK", acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = block_record_handle; + host.document_mut().add_entity(EntityType::Block(block)).unwrap(); + let mut block_end = acadrust::entities::BlockEnd::new(); + block_end.common.handle = block_end_handle; + block_end.common.owner_handle = block_record_handle; + host.document_mut().add_entity(EntityType::BlockEnd(block_end)).unwrap(); + let insert = acadrust::entities::Insert::new( + "TAGBLOCK", acadrust::types::Vector3::new(10.0, 0.0, 0.0)); + let insert_handle = host.document_mut().add_entity(EntityType::Insert(insert)).unwrap(); + + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, + crate::plugin::v4_support::notification_handler(), + ).expect("spawn staged Python plugin"); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.active_document.create_entity('AttributeDefinition',", + "owner_handle={},tag='PART_NO',prompt='Part number',default_value='PN-001',", + "insertion_point={{'x':1.0,'y':2.0,'z':0.0}},height=2.5).handle" + ), block_record_handle.value())); + let handle = host.document().entities() + .find(|entity| matches!(entity, EntityType::AttributeDefinition(_))) + .expect("Python created an AttributeDefinition").common().handle; + let Some(EntityType::AttributeDefinition(created)) = host.document().get_entity(handle) else { + panic!("expected AttributeDefinition"); + }; + assert_eq!(created.common.owner_handle, block_record_handle); + assert_eq!(created.tag, "PART_NO"); + assert_eq!(created.text_style, "Standard"); + assert!(host.document().block_records.get("TAGBLOCK").unwrap() + .entity_handles.contains(&handle)); + let created = created.clone(); + dispatch(&mut host, &format!( + "PY_EVAL (ocs.active_document.entities[{}].tag, ocs.active_document.entities[{}].owner_handle)", + handle.value(), handle.value())); + let readback = &host.app.command_line.history.last().unwrap().text; + assert!(readback.contains("PART_NO") && readback.contains(&block_record_handle.value().to_string())); + + let script_path = std::env::temp_dir().join(format!( + "ocs_attribute_definition_document_model_{}.py", std::process::id())); + std::fs::write(&script_path, format!(concat!( + "doc = ocs.active_document\n", + "definition = doc.entities[{}]\n", + "with doc.transaction('Edit attribute definition'):\n", + " definition.insertion_point = (4.0, 5.0, 0.0)\n", + " definition.default_value = 'PN-002'\n", + " definition.rotation = 0.25\n", + " definition.width_factor = 1.25\n", + "doc.selection = [definition]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script_path.display())); + let _ = std::fs::remove_file(&script_path); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::AttributeDefinition(edited) = &expected else { unreachable!() }; + assert_eq!(edited.insertion_point, acadrust::types::Vector3::new(4.0, 5.0, 0.0)); + assert_eq!(edited.default_value, "PN-002"); + assert_eq!(edited.rotation, 0.25); + assert_eq!(edited.width_factor, 1.25); + assert_eq!(edited.common, created.common); + assert_eq!(edited.tag, created.tag); + assert_eq!(edited.prompt, created.prompt); + assert_eq!(edited.text_style, created.text_style); + assert_eq!(edited.flags, created.flags); + assert_eq!(edited.embedded_mtext, created.embedded_mtext); + assert_eq!(host.selection(), vec![handle]); + assert!(matches!(host.document().get_entity(insert_handle), Some(EntityType::Insert(value)) + if value.block_name == "TAGBLOCK")); + + let render = edited.to_render(host.document()).expect("attribute definition render"); + let crate::scene::convert::acad_to_render::RenderObject::Text(strokes) = render.object else { + panic!("expected attribute definition text"); + }; + assert!(!strokes.is_empty()); + assert!(strokes.iter().any(|stroke| stroke.run.as_ref() + .is_some_and(|run| run.text.contains("PN-002")))); + + for (label, patch, message) in [ + ("Reject tag", "'tag':'BAD TAG'", "whitespace"), + ("Reject height", "'height':0.0", "greater than zero"), + ("Reject style", "'text_style':'Missing'", "does not exist"), + ("Reject owner", "'owner_handle':999999", "read-only"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many({label:?}, [{{'handle':{}, {patch}}}])", handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + assert!(host.app.command_line.history.last().unwrap().text.contains(message)); + } + + let assert_persisted = |label: &str, document: &CadDocument, full_text_state: bool| { + let actual = document.get_entity(handle); + assert!(matches!(actual, Some(EntityType::AttributeDefinition(value)) + if value.common.owner_handle == block_record_handle + && value.tag == "PART_NO" && value.default_value == "PN-002" + && value.insertion_point == acadrust::types::Vector3::new(4.0, 5.0, 0.0) + && (value.rotation - 0.25).abs() < 1e-12), "{label}: {actual:?}"); + if full_text_state { + assert!(matches!(actual, Some(EntityType::AttributeDefinition(value)) + if value.width_factor == 1.25 && value.text_style == "Standard" + && value.prompt == "Part number"), "{label}: {actual:?}"); + } + assert!(document.block_records.get("TAGBLOCK").unwrap().entity_handles.contains(&handle)); + assert!(matches!(document.get_entity(insert_handle), Some(EntityType::Insert(value)) + if value.block_name == "TAGBLOCK")); + }; + let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + let dwg_doc = crate::io::load_bytes("attribute-definition.dwg", dwg_bytes).unwrap(); + assert_persisted("DWG", &dwg_doc, true); + let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap(); + let dxf_doc = crate::io::load_bytes("attribute-definition.dxf", dxf_bytes).unwrap(); + // The pinned cadcodec DXF ATTDEF reader retains identity, placement, + // height, value, prompt and rotation, but currently drops several + // optional AcDbText fields such as width factor. Keep that external + // codec gap explicit in the coverage ledger rather than claiming a + // complete DXF property round trip here. + assert_persisted("DXF", &dxf_doc, false); + + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + // CadDocument intentionally leaves the block membership slot in place + // so delta undo can restore the shared entity without rewriting the + // record. Writers must still omit the absent entity. + let deleted_dwg = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + assert!(crate::io::load_bytes("attribute-definition-deleted.dwg", deleted_dwg) + .unwrap().get_entity(handle).is_none()); + let deleted_dxf = acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap(); + assert!(crate::io::load_bytes("attribute-definition-deleted.dxf", deleted_dxf) + .unwrap().get_entity(handle).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert!(matches!(app.tabs[0].scene.document.get_entity(handle), + Some(EntityType::AttributeDefinition(value)) + if value.default_value == "PN-001" && value.insertion_point.x == 1.0)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; diff --git a/src/io/mod.rs b/src/io/mod.rs index 04e8de258..8fab9dda2 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -2174,21 +2174,16 @@ fn fix_viewport_status_flags(doc: &mut CadDocument) { } } -/// The acadrust DXF reader stores several rotation fields directly from DXF -/// group code 50 in degrees, while DWG and our own creation code store radians. -/// Apply to_radians() on load so tessellation can call cos/sin uniformly. +/// The acadrust DXF reader still stores Shape rotation directly from group code +/// 50 in degrees, while DWG and our own creation code store radians. Attribute +/// entities and definitions are converted by the reader itself and must not be +/// converted a second time here. fn fix_dxf_dimension_rotations(doc: &mut CadDocument) { for entity in doc.entities_mut() { match entity { // Dimension angles (rotation / text / oblique) are converted // degrees->radians inside the acadrust DXF reader now, so a // dimension arm here would double-convert. - EntityType::AttributeDefinition(a) => { - a.rotation = a.rotation.to_radians(); - } - EntityType::AttributeEntity(a) => { - a.rotation = a.rotation.to_radians(); - } EntityType::Shape(s) => { s.rotation = s.rotation.to_radians(); } From 5ef7000b675d08b2637cacc5c1e9a13e9cc43dbf Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 12:48:36 +0200 Subject: [PATCH 10/76] Record AttributeDefinition coverage status --- docs/plugin-host-model-completion-plan.md | 8 ++++++-- docs/plugin-host-model-coverage-ledger.md | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 310050382..5c54e0c85 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -4,7 +4,7 @@ Build a general Python document model over the OCS host API for all **43 canvas kinds**. A kind is **complete** only after the per-kind gate below passes. Merely appearing in a converter or allowing a `layer` edit is partial coverage. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). Do not resume the separate Lisp-to-Python conversion work. -As of 19 September 2026, the OCS fork branch `plugin/host-model-api` is pushed at `db61379b` in `felixriestra/OpenCADStudio`; the PandoraBox adapter on `main` is pushed at `d0cc85e` in `felixriestra/PandoraBox`. Host API is v7. The Python feature has mapped writes for 16 initial geometry kinds plus **update-only** `Insert`; this is **not** 17 completed kinds. The other 26 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. +As of 19 September 2026, the current AttributeDefinition increment is OCS commit `5b854bd1` on `plugin/host-model-api` and PandoraBox commit `bb259ff` on `main`. Host API is v7. The Python feature has explicit writes for **20 of 43** canvas kinds: 19 are creatable and `Insert` remains update-only. This is **not** 20 completed kinds: only Tolerance and Shape pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the pinned acadrust DXF reader drops optional ATTDEF fields. The other 23 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. Repositories: @@ -93,7 +93,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Start with the **Phase 8 integration harness and coverage ledger**. Re-evaluate the existing 17 mapped kinds against the strict completion gate. Then take `Tolerance` as the first new kind, unless its required style or persistence path is blocked; record that and proceed to `Shape`. Keep the exact remaining-kind table above as the queue and update statuses with evidence after each increment. +Continue with **AttributeEntity**, using the existing block fixture and new creation-time `owner_handle` support. Its owner must be a real linked `Insert`, and the test must preserve sequence/attribute references through edit, deletion, undo/redo and both file formats. AttributeDefinition is mapped and integration-tested but remains open at W: pinned acadrust `568a12c` loses optional ATTDEF fields such as width factor on DXF read. Do not mark it complete until that dependency is fixed and the full property state passes DXF save/reopen. Keep the exact remaining-kind table above as the queue and update statuses with evidence after each increment. ## Phase 8 progress log @@ -109,3 +109,7 @@ Start with the **Phase 8 integration harness and coverage ledger**. Re-evaluate - `staged_python_tolerance_lifecycle_over_real_ipc` passes the full C/R/E/D/U/I/W/V/P gate with the actual runner: high-level create/read/transaction edit/delete, live canvas selection, undo/redo, invalid direction and missing style rollback, untouched-field preservation, and DWG/DXF reopen. Tolerance is recorded as **Complete** in the ledger. The queue now continues with `Shape`. - Closed `Shape` with a generated SHX fixture and a real shape-file `TextStyle`. Host-owned binding resolves the Python style name to the stable table handle required by DWG; validation rejects missing/non-shape styles and invalid geometry. The adapter maps all stable shape fields and keeps the bound style handle read-only. - `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the actual runner, checks the real SHX linework before and after DWG/DXF reopen, and proves that invalid size/style edits remain atomic. The DXF renderer fallback now searches named `is_shape_file` styles as well as legacy unnamed styles. Shape is **Complete**; the queue now continues with `AttributeDefinition`. +- Added creation-time `owner_handle` to the generated document-model adapter. It is present in snapshots and accepted by `doc.create_entity`, but rejected after creation; host transactions independently reject owner changes. Generic host validation now requires a non-null canvas owner to resolve to a block record, except `AttributeEntity`, whose owner may be an `Insert`. +- Mapped stable AttributeDefinition fields and validated tag, placement, normal, height, width, angles, text style, line count and block ownership. `embedded_mtext` stays snapshot-readable but unmapped so partial Python edits preserve its linked R2018+ layout payload. +- `staged_python_attribute_definition_lifecycle_over_real_ipc` passes the real create/read/edit/delete/undo/selection/render/validation/portable-build path using a structurally valid block and linked Insert. Full DWG state and core DXF geometry survive reopen; deletion stays absent after both formats. OCS also stopped double-converting ATTDEF/ATTRIB DXF rotation already converted by acadrust. +- AttributeDefinition remains **Integration-tested / DXF blocked**, not Complete: acadrust `568a12c`'s ATTDEF DXF reader does not restore optional AcDbText fields including width factor. The real test and ledger record that exact W-gate gap. The queue now continues with `AttributeEntity`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index fd99623aa..b2d779706 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **19 of 43 canvas kinds have explicit geometry writes** (18 creatable, plus update-only `Insert`), **2 kinds are complete**, and 24 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **20 of 43 canvas kinds have explicit geometry writes** (19 creatable, plus update-only `Insert`), **2 kinds are complete**, and 23 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = portable H7 v5 build. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -25,7 +25,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Insert | Update-only | Transform transaction and undo tested; Python creation, deletion, real IPC/GUI and DWG/DXF gates pending. | | Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | | Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | -| AttributeDefinition | Layer-only | Requires block-definition fixture. | +| AttributeDefinition | **Integration-tested / DXF blocked** | Host commit `5b854bd1` and adapter commit `bb259ff` map block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, render text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open** because pinned acadrust `568a12c` drops optional ATTDEF fields including width factor while reading DXF; the regression test keeps this limitation explicit. `embedded_mtext` mutation also remains unsupported pending linked-layout validation. | | AttributeEntity | Layer-only | Requires linked Insert and attribute-sequence fixture. | | Hatch | Layer-only | Requires valid boundary and pattern fixtures. | | Leader | Layer-only | Requires annotation-linked fixture. | From cf2a923568cbc3f85ee520a13737c1a392d37234 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 13:16:40 +0200 Subject: [PATCH 11/76] Add nested attribute entity host support --- crates/ocs_plugin_api/build.rs | 272 ++- .../entity_coverage_policy.json | 6 +- crates/ocs_plugin_api/src/entity_coverage.rs | 1099 ++++++++--- crates/ocs_plugin_api/src/ipc/server.rs | 27 +- crates/ocs_plugin_api/src/shm.rs | 34 +- src/app/plugin_host.rs | 1612 ++++++++++++++--- 6 files changed, 2509 insertions(+), 541 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 83eff3320..0f7e09cd0 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -17,7 +17,9 @@ 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}; +use entity_coverage_schema::{ + EntityCoverageCatalog, EntityKindCoverage, EntityScope, ModelAccess, PropertyCoverage, +}; #[derive(serde::Deserialize)] struct EntityCoveragePolicy { @@ -506,41 +508,71 @@ fn generate_type_registry(out_dir: &Path) { 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")) + let policy: EntityCoveragePolicy = + serde_json::from_str(include_str!("entity_coverage_policy.json")) .expect("valid entity coverage policy"); let variants = ®istry.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 + .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}"); + 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}"); + 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}"); + assert!( + policy.editable.contains_key(name), + "aliases for non-editable kind: {name}" + ); } let common = ®istry.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 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 = editable.iter().chain(&readable).cloned().collect(); - assert_eq!(unresolved.len(), editable.len() + readable.len(), "duplicate mapped property in {kind}"); + 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: "".into(), type_id: "String".into(), - optional: false, is_sequence: false, snapshot_readable: true, - model_access: ModelAccess::ReadOnly, validation: "none".into(), + name: "kind".into(), + source_path: "".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() { @@ -549,69 +581,203 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { "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, + 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(), + } 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_type = variant + .fields + .first() + .expect("entity variant payload") + .type_id + .clone(); let shape = ®istry.types[&shape_type]; for field in &shape.fields { - if field.name == "common" { continue; } + 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 + 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 }) + ( + (*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")) { + 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" + ) + ) { "transaction_geometry" - } else { "type_conversion_only" }; + } 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, + 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, }); } - assert!(unresolved.is_empty(), "{kind} has coverage keys absent from registry: {unresolved:?}"); - entity_kinds.push(EntityKindCoverage { kind: kind.clone(), scope, properties }); + 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()) + 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"); } diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index e4f372ee4..341449ebc 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -21,12 +21,14 @@ "Insert": ["layer", "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"] + "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"] }, "readable": { "Insert": ["block_name"], "Tolerance": ["dimension_style_handle"], - "Shape": ["style_handle"] + "Shape": ["style_handle"], + "AttributeEntity": ["attdef_handle"] }, "aliases": { "Polyline": {"closed": "flags"}, diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 17a52851b..cbfa829d0 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -16,35 +16,49 @@ pub fn get_embedded_entity_coverage_json() -> &'static str { /// JSON does not preserve non-finite floating-point values, so this is an /// inspection format and must remain separate from the typed entity write path. #[cfg(feature = "host")] -pub fn entity_snapshot(entity: &crate::host::EntityType) -> Result { +pub fn entity_snapshot( + entity: &crate::host::EntityType, +) -> Result { serde_json::to_value(entity) } #[cfg(feature = "host")] fn finite_vector(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> { - if value.x.is_finite() && value.y.is_finite() && value.z.is_finite() { Ok(()) } - else { Err(format!("{name} must contain finite coordinates")) } + if value.x.is_finite() && value.y.is_finite() && value.z.is_finite() { + Ok(()) + } else { + Err(format!("{name} must contain finite coordinates")) + } } #[cfg(feature = "host")] fn unit_direction(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> { finite_vector(name, value)?; let length = value.x.hypot(value.y).hypot(value.z); - if (length - 1.0).abs() <= 1e-6 { Ok(()) } - else { Err(format!("{name} must be a unit vector")) } + if (length - 1.0).abs() <= 1e-6 { + Ok(()) + } else { + Err(format!("{name} must be a unit vector")) + } } #[cfg(feature = "host")] fn solid_normal(name: &str, value: &crate::host::acadrust::types::Vector3) -> Result<(), String> { finite_vector(name, value)?; - if value.x.hypot(value.y).hypot(value.z) > 0.0 { Ok(()) } - else { Err(format!("{name} must be nonzero")) } + if value.x.hypot(value.y).hypot(value.z) > 0.0 { + Ok(()) + } else { + Err(format!("{name} must be nonzero")) + } } #[cfg(feature = "host")] fn insert_scale(name: &str, value: f64) -> Result<(), String> { - if value.is_finite() && value.abs() >= 1e-12 { Ok(()) } - else { Err(format!("{name} must be finite and nonzero")) } + if value.is_finite() && value.abs() >= 1e-12 { + Ok(()) + } else { + Err(format!("{name} must be finite and nonzero")) + } } /// Validate newly created geometry for the kinds whose mapped fields have @@ -52,7 +66,9 @@ fn insert_scale(name: &str, value: f64) -> Result<(), String> { #[cfg(feature = "host")] pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<(), String> { use crate::host::EntityType; - if entity.common().layer.trim().is_empty() { return Err("layer name is empty".into()); } + if entity.common().layer.trim().is_empty() { + return Err("layer name is empty".into()); + } match entity { EntityType::Ray(value) => { finite_vector("Ray.base_point", &value.base_point)?; @@ -64,17 +80,27 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } EntityType::Solid(value) => { for (name, corner) in [ - ("first_corner", &value.first_corner), ("second_corner", &value.second_corner), - ("third_corner", &value.third_corner), ("fourth_corner", &value.fourth_corner), - ] { finite_vector(&format!("Solid.{name}"), corner)?; } + ("first_corner", &value.first_corner), + ("second_corner", &value.second_corner), + ("third_corner", &value.third_corner), + ("fourth_corner", &value.fourth_corner), + ] { + finite_vector(&format!("Solid.{name}"), corner)?; + } solid_normal("Solid.normal", &value.normal)?; - if !value.thickness.is_finite() { return Err("Solid.thickness must be finite".into()); } + if !value.thickness.is_finite() { + return Err("Solid.thickness must be finite".into()); + } } EntityType::Face3D(value) => { for (name, corner) in [ - ("first_corner", &value.first_corner), ("second_corner", &value.second_corner), - ("third_corner", &value.third_corner), ("fourth_corner", &value.fourth_corner), - ] { finite_vector(&format!("Face3D.{name}"), corner)?; } + ("first_corner", &value.first_corner), + ("second_corner", &value.second_corner), + ("third_corner", &value.third_corner), + ("fourth_corner", &value.fourth_corner), + ] { + finite_vector(&format!("Face3D.{name}"), corner)?; + } if value.invisible_edges.bits() & !0x0f != 0 { return Err("Face3D.invisible_edges has unknown bits".into()); } @@ -83,47 +109,128 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() finite_vector("Tolerance.insertion_point", &value.insertion_point)?; unit_direction("Tolerance.direction", &value.direction)?; solid_normal("Tolerance.normal", &value.normal)?; - if value.text.trim().is_empty() { return Err("Tolerance.text is empty".into()); } - if value.dimension_style_name.trim().is_empty() { return Err("Tolerance.dimension_style_name is empty".into()); } + if value.text.trim().is_empty() { + return Err("Tolerance.text is empty".into()); + } + if value.dimension_style_name.trim().is_empty() { + return Err("Tolerance.dimension_style_name is empty".into()); + } if !value.text_height.is_finite() || value.text_height <= 0.0 { return Err("Tolerance.text_height must be finite and greater than zero".into()); } - if !value.dimension_gap.is_finite() { return Err("Tolerance.dimension_gap must be finite".into()); } + if !value.dimension_gap.is_finite() { + return Err("Tolerance.dimension_gap must be finite".into()); + } } EntityType::Shape(value) => { finite_vector("Shape.insertion_point", &value.insertion_point)?; solid_normal("Shape.normal", &value.normal)?; - if !value.size.is_finite() || value.size <= 0.0 { return Err("Shape.size must be finite and greater than zero".into()); } - if value.shape_name.trim().is_empty() { return Err("Shape.shape_name is empty".into()); } - if !(1..=i16::MAX as i32).contains(&value.shape_number) { return Err("Shape.shape_number must be between 1 and 32767".into()); } - if !value.rotation.is_finite() { return Err("Shape.rotation must be finite".into()); } + if !value.size.is_finite() || value.size <= 0.0 { + return Err("Shape.size must be finite and greater than zero".into()); + } + if value.shape_name.trim().is_empty() { + return Err("Shape.shape_name is empty".into()); + } + if !(1..=i16::MAX as i32).contains(&value.shape_number) { + return Err("Shape.shape_number must be between 1 and 32767".into()); + } + if !value.rotation.is_finite() { + return Err("Shape.rotation must be finite".into()); + } if !value.relative_x_scale.is_finite() || value.relative_x_scale.abs() < 1e-12 { return Err("Shape.relative_x_scale must be finite and nonzero".into()); } - if !value.oblique_angle.is_finite() || value.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 { + if !value.oblique_angle.is_finite() + || value.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 + { return Err("Shape.oblique_angle must be finite and between -PI/2 and PI/2".into()); } - if !value.thickness.is_finite() { return Err("Shape.thickness must be finite".into()); } - if value.style_name.trim().is_empty() { return Err("Shape.style_name is empty".into()); } + if !value.thickness.is_finite() { + return Err("Shape.thickness must be finite".into()); + } + if value.style_name.trim().is_empty() { + return Err("Shape.style_name is empty".into()); + } } EntityType::AttributeDefinition(value) => { - finite_vector("AttributeDefinition.insertion_point", &value.insertion_point)?; - finite_vector("AttributeDefinition.alignment_point", &value.alignment_point)?; + finite_vector( + "AttributeDefinition.insertion_point", + &value.insertion_point, + )?; + finite_vector( + "AttributeDefinition.alignment_point", + &value.alignment_point, + )?; solid_normal("AttributeDefinition.normal", &value.normal)?; if value.tag.trim().is_empty() || value.tag.chars().any(char::is_whitespace) { - return Err("AttributeDefinition.tag must be nonempty and contain no whitespace".into()); + return Err( + "AttributeDefinition.tag must be nonempty and contain no whitespace".into(), + ); + } + if !value.height.is_finite() || value.height <= 0.0 { + return Err( + "AttributeDefinition.height must be finite and greater than zero".into(), + ); + } + if !value.rotation.is_finite() { + return Err("AttributeDefinition.rotation must be finite".into()); } - if !value.height.is_finite() || value.height <= 0.0 { return Err("AttributeDefinition.height must be finite and greater than zero".into()); } - if !value.rotation.is_finite() { return Err("AttributeDefinition.rotation must be finite".into()); } if !value.width_factor.is_finite() || value.width_factor.abs() < 1e-12 { return Err("AttributeDefinition.width_factor must be finite and nonzero".into()); } - if !value.oblique_angle.is_finite() || value.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 { - return Err("AttributeDefinition.oblique_angle must be finite and between -PI/2 and PI/2".into()); + if !value.oblique_angle.is_finite() + || value.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 + { + return Err( + "AttributeDefinition.oblique_angle must be finite and between -PI/2 and PI/2" + .into(), + ); + } + if value.text_style.trim().is_empty() { + return Err("AttributeDefinition.text_style is empty".into()); + } + if value.field_length < 0 { + return Err("AttributeDefinition.field_length must be nonnegative".into()); + } + if value.line_count < 1 { + return Err("AttributeDefinition.line_count must be greater than zero".into()); + } + } + EntityType::AttributeEntity(value) => { + finite_vector("AttributeEntity.insertion_point", &value.insertion_point)?; + finite_vector("AttributeEntity.alignment_point", &value.alignment_point)?; + solid_normal("AttributeEntity.normal", &value.normal)?; + if value.tag.trim().is_empty() || value.tag.chars().any(char::is_whitespace) { + return Err( + "AttributeEntity.tag must be nonempty and contain no whitespace".into(), + ); + } + if !value.height.is_finite() || value.height <= 0.0 { + return Err("AttributeEntity.height must be finite and greater than zero".into()); + } + if !value.rotation.is_finite() { + return Err("AttributeEntity.rotation must be finite".into()); + } + if !value.width_factor.is_finite() || value.width_factor.abs() < 1e-12 { + return Err("AttributeEntity.width_factor must be finite and nonzero".into()); + } + if !value.oblique_angle.is_finite() + || value.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 + { + return Err( + "AttributeEntity.oblique_angle must be finite and between -PI/2 and PI/2" + .into(), + ); + } + if value.text_style.trim().is_empty() { + return Err("AttributeEntity.text_style is empty".into()); + } + if value.field_length < 0 { + return Err("AttributeEntity.field_length must be nonnegative".into()); + } + if value.line_count < 1 { + return Err("AttributeEntity.line_count must be greater than zero".into()); } - if value.text_style.trim().is_empty() { return Err("AttributeDefinition.text_style is empty".into()); } - if value.field_length < 0 { return Err("AttributeDefinition.field_length must be nonnegative".into()); } - if value.line_count < 1 { return Err("AttributeDefinition.line_count must be greater than zero".into()); } } _ => {} } @@ -140,32 +247,58 @@ pub fn validate_entity_mutation( ) -> Result<(), String> { use crate::host::EntityType; if before.common().layer != after.common().layer { - if after.common().layer.trim().is_empty() { return Err("layer name is empty".into()); } - if matches!(after, EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::Seqend(_) - | EntityType::Extended(_) | EntityType::Unknown(_)) { + if after.common().layer.trim().is_empty() { + return Err("layer name is empty".into()); + } + if matches!( + after, + EntityType::Block(_) + | EntityType::BlockEnd(_) + | EntityType::Seqend(_) + | EntityType::Extended(_) + | EntityType::Unknown(_) + ) { return Err("entity kind does not support canvas layer edits".into()); } } let finite3 = |name: &str, x: f64, y: f64, z: f64| { - if x.is_finite() && y.is_finite() && z.is_finite() { Ok(()) } - else { Err(format!("{name} must contain finite coordinates")) } + if x.is_finite() && y.is_finite() && z.is_finite() { + Ok(()) + } else { + Err(format!("{name} must contain finite coordinates")) + } }; let same3 = |a: &crate::host::acadrust::types::Vector3, b: &crate::host::acadrust::types::Vector3| { - a.x.to_bits() == b.x.to_bits() && a.y.to_bits() == b.y.to_bits() && a.z.to_bits() == b.z.to_bits() + a.x.to_bits() == b.x.to_bits() + && a.y.to_bits() == b.y.to_bits() + && a.z.to_bits() == b.z.to_bits() }; let radius = |name: &str, value: f64| { - if value.is_finite() && value > 0.0 { Ok(()) } - else { Err(format!("{name} must be finite and greater than zero")) } + if value.is_finite() && value > 0.0 { + Ok(()) + } else { + Err(format!("{name} must be finite and greater than zero")) + } }; - let changed3 = |name: &str, old: &crate::host::acadrust::types::Vector3, + let changed3 = |name: &str, + old: &crate::host::acadrust::types::Vector3, new: &crate::host::acadrust::types::Vector3| { - if !same3(old, new) { finite_vector(name, new) } else { Ok(()) } + if !same3(old, new) { + finite_vector(name, new) + } else { + Ok(()) + } }; match (before, after) { (EntityType::Point(old), EntityType::Point(new)) => { if !same3(&old.location, &new.location) { - finite3("Point.location", new.location.x, new.location.y, new.location.z)?; + finite3( + "Point.location", + new.location.x, + new.location.y, + new.location.z, + )?; } } (EntityType::Line(old), EntityType::Line(new)) => { @@ -180,21 +313,29 @@ pub fn validate_entity_mutation( if !same3(&old.center, &new.center) { finite3("Circle.center", new.center.x, new.center.y, new.center.z)?; } - if old.radius.to_bits() != new.radius.to_bits() { radius("Circle.radius", new.radius)?; } + if old.radius.to_bits() != new.radius.to_bits() { + radius("Circle.radius", new.radius)?; + } } (EntityType::Arc(old), EntityType::Arc(new)) => { if !same3(&old.center, &new.center) { finite3("Arc.center", new.center.x, new.center.y, new.center.z)?; } - if old.radius.to_bits() != new.radius.to_bits() { radius("Arc.radius", new.radius)?; } + if old.radius.to_bits() != new.radius.to_bits() { + radius("Arc.radius", new.radius)?; + } } (EntityType::Ray(old), EntityType::Ray(new)) => { changed3("Ray.base_point", &old.base_point, &new.base_point)?; - if !same3(&old.direction, &new.direction) { unit_direction("Ray.direction", &new.direction)?; } + if !same3(&old.direction, &new.direction) { + unit_direction("Ray.direction", &new.direction)?; + } } (EntityType::XLine(old), EntityType::XLine(new)) => { changed3("XLine.base_point", &old.base_point, &new.base_point)?; - if !same3(&old.direction, &new.direction) { unit_direction("XLine.direction", &new.direction)?; } + if !same3(&old.direction, &new.direction) { + unit_direction("XLine.direction", &new.direction)?; + } } (EntityType::Solid(old), EntityType::Solid(new)) => { for (name, old_corner, new_corner) in [ @@ -202,8 +343,12 @@ pub fn validate_entity_mutation( ("second_corner", &old.second_corner, &new.second_corner), ("third_corner", &old.third_corner, &new.third_corner), ("fourth_corner", &old.fourth_corner, &new.fourth_corner), - ] { changed3(&format!("Solid.{name}"), old_corner, new_corner)?; } - if !same3(&old.normal, &new.normal) { solid_normal("Solid.normal", &new.normal)?; } + ] { + changed3(&format!("Solid.{name}"), old_corner, new_corner)?; + } + if !same3(&old.normal, &new.normal) { + solid_normal("Solid.normal", &new.normal)?; + } if old.thickness.to_bits() != new.thickness.to_bits() && !new.thickness.is_finite() { return Err("Solid.thickness must be finite".into()); } @@ -214,24 +359,34 @@ pub fn validate_entity_mutation( ("second_corner", &old.second_corner, &new.second_corner), ("third_corner", &old.third_corner, &new.third_corner), ("fourth_corner", &old.fourth_corner, &new.fourth_corner), - ] { changed3(&format!("Face3D.{name}"), old_corner, new_corner)?; } - if old.invisible_edges != new.invisible_edges && new.invisible_edges.bits() & !0x0f != 0 { + ] { + changed3(&format!("Face3D.{name}"), old_corner, new_corner)?; + } + if old.invisible_edges != new.invisible_edges && new.invisible_edges.bits() & !0x0f != 0 + { return Err("Face3D.invisible_edges has unknown bits".into()); } } (EntityType::Insert(old), EntityType::Insert(new)) => { - if old.block_name != new.block_name || old.attributes != new.attributes || - old.view_rep_handle != new.view_rep_handle || old.seqend_handle != new.seqend_handle { + if old.block_name != new.block_name + || old.attributes != new.attributes + || old.view_rep_handle != new.view_rep_handle + || old.seqend_handle != new.seqend_handle + { return Err("Insert block identity and attached records cannot change in a geometry transaction".into()); } changed3("Insert.insert_point", &old.insert_point, &new.insert_point)?; - if !same3(&old.normal, &new.normal) { solid_normal("Insert.normal", &new.normal)?; } + if !same3(&old.normal, &new.normal) { + solid_normal("Insert.normal", &new.normal)?; + } for (name, before, after) in [ ("x_scale", old.x_scale(), new.x_scale()), ("y_scale", old.y_scale(), new.y_scale()), ("z_scale", old.z_scale(), new.z_scale()), ] { - if before.to_bits() != after.to_bits() { insert_scale(&format!("Insert.{name}"), after)?; } + if before.to_bits() != after.to_bits() { + insert_scale(&format!("Insert.{name}"), after)?; + } } for (name, before, after) in [ ("rotation", old.rotation, new.rotation), @@ -242,68 +397,123 @@ pub fn validate_entity_mutation( return Err(format!("Insert.{name} must be finite")); } } - if (old.column_count != new.column_count && new.column_count == 0) || - (old.row_count != new.row_count && new.row_count == 0) { + if (old.column_count != new.column_count && new.column_count == 0) + || (old.row_count != new.row_count && new.row_count == 0) + { return Err("Insert array counts must be greater than zero".into()); } } (EntityType::Tolerance(old), EntityType::Tolerance(new)) => { - changed3("Tolerance.insertion_point", &old.insertion_point, &new.insertion_point)?; - if !same3(&old.direction, &new.direction) { unit_direction("Tolerance.direction", &new.direction)?; } - if !same3(&old.normal, &new.normal) { solid_normal("Tolerance.normal", &new.normal)?; } - if old.text != new.text && new.text.trim().is_empty() { return Err("Tolerance.text is empty".into()); } - if old.dimension_style_name != new.dimension_style_name && new.dimension_style_name.trim().is_empty() { + changed3( + "Tolerance.insertion_point", + &old.insertion_point, + &new.insertion_point, + )?; + if !same3(&old.direction, &new.direction) { + unit_direction("Tolerance.direction", &new.direction)?; + } + if !same3(&old.normal, &new.normal) { + solid_normal("Tolerance.normal", &new.normal)?; + } + if old.text != new.text && new.text.trim().is_empty() { + return Err("Tolerance.text is empty".into()); + } + if old.dimension_style_name != new.dimension_style_name + && new.dimension_style_name.trim().is_empty() + { return Err("Tolerance.dimension_style_name is empty".into()); } if old.text_height.to_bits() != new.text_height.to_bits() - && (!new.text_height.is_finite() || new.text_height <= 0.0) { + && (!new.text_height.is_finite() || new.text_height <= 0.0) + { return Err("Tolerance.text_height must be finite and greater than zero".into()); } - if old.dimension_gap.to_bits() != new.dimension_gap.to_bits() && !new.dimension_gap.is_finite() { + if old.dimension_gap.to_bits() != new.dimension_gap.to_bits() + && !new.dimension_gap.is_finite() + { return Err("Tolerance.dimension_gap must be finite".into()); } if old.dimension_style_handle != new.dimension_style_handle - && old.dimension_style_name == new.dimension_style_name { + && old.dimension_style_name == new.dimension_style_name + { return Err("Tolerance.dimension_style_handle is read-only".into()); } } (EntityType::Shape(old), EntityType::Shape(new)) => { - changed3("Shape.insertion_point", &old.insertion_point, &new.insertion_point)?; - if !same3(&old.normal, &new.normal) { solid_normal("Shape.normal", &new.normal)?; } - if old.size.to_bits() != new.size.to_bits() && (!new.size.is_finite() || new.size <= 0.0) { + changed3( + "Shape.insertion_point", + &old.insertion_point, + &new.insertion_point, + )?; + if !same3(&old.normal, &new.normal) { + solid_normal("Shape.normal", &new.normal)?; + } + if old.size.to_bits() != new.size.to_bits() + && (!new.size.is_finite() || new.size <= 0.0) + { return Err("Shape.size must be finite and greater than zero".into()); } - if old.shape_name != new.shape_name && new.shape_name.trim().is_empty() { return Err("Shape.shape_name is empty".into()); } - if old.shape_number != new.shape_number && !(1..=i16::MAX as i32).contains(&new.shape_number) { + if old.shape_name != new.shape_name && new.shape_name.trim().is_empty() { + return Err("Shape.shape_name is empty".into()); + } + if old.shape_number != new.shape_number + && !(1..=i16::MAX as i32).contains(&new.shape_number) + { return Err("Shape.shape_number must be between 1 and 32767".into()); } - if old.rotation.to_bits() != new.rotation.to_bits() && !new.rotation.is_finite() { return Err("Shape.rotation must be finite".into()); } + if old.rotation.to_bits() != new.rotation.to_bits() && !new.rotation.is_finite() { + return Err("Shape.rotation must be finite".into()); + } if old.relative_x_scale.to_bits() != new.relative_x_scale.to_bits() - && (!new.relative_x_scale.is_finite() || new.relative_x_scale.abs() < 1e-12) { + && (!new.relative_x_scale.is_finite() || new.relative_x_scale.abs() < 1e-12) + { return Err("Shape.relative_x_scale must be finite and nonzero".into()); } if old.oblique_angle.to_bits() != new.oblique_angle.to_bits() - && (!new.oblique_angle.is_finite() || new.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2) { + && (!new.oblique_angle.is_finite() + || new.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2) + { return Err("Shape.oblique_angle must be finite and between -PI/2 and PI/2".into()); } if old.thickness.to_bits() != new.thickness.to_bits() && !new.thickness.is_finite() { return Err("Shape.thickness must be finite".into()); } - if old.style_name != new.style_name && new.style_name.trim().is_empty() { return Err("Shape.style_name is empty".into()); } + if old.style_name != new.style_name && new.style_name.trim().is_empty() { + return Err("Shape.style_name is empty".into()); + } if old.style_handle != new.style_handle - && !(old.style_name != new.style_name && new.style_handle.is_some()) { + && !(old.style_name != new.style_name && new.style_handle.is_some()) + { return Err("Shape.style_handle is read-only".into()); } } (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { - changed3("AttributeDefinition.insertion_point", &old.insertion_point, &new.insertion_point)?; - changed3("AttributeDefinition.alignment_point", &old.alignment_point, &new.alignment_point)?; - if !same3(&old.normal, &new.normal) { solid_normal("AttributeDefinition.normal", &new.normal)?; } - if old.tag != new.tag && (new.tag.trim().is_empty() || new.tag.chars().any(char::is_whitespace)) { - return Err("AttributeDefinition.tag must be nonempty and contain no whitespace".into()); + changed3( + "AttributeDefinition.insertion_point", + &old.insertion_point, + &new.insertion_point, + )?; + changed3( + "AttributeDefinition.alignment_point", + &old.alignment_point, + &new.alignment_point, + )?; + if !same3(&old.normal, &new.normal) { + solid_normal("AttributeDefinition.normal", &new.normal)?; } - if old.height.to_bits() != new.height.to_bits() && (!new.height.is_finite() || new.height <= 0.0) { - return Err("AttributeDefinition.height must be finite and greater than zero".into()); + if old.tag != new.tag + && (new.tag.trim().is_empty() || new.tag.chars().any(char::is_whitespace)) + { + return Err( + "AttributeDefinition.tag must be nonempty and contain no whitespace".into(), + ); + } + if old.height.to_bits() != new.height.to_bits() + && (!new.height.is_finite() || new.height <= 0.0) + { + return Err( + "AttributeDefinition.height must be finite and greater than zero".into(), + ); } for (name, before, after) in [ ("rotation", old.rotation, new.rotation), @@ -314,12 +524,16 @@ pub fn validate_entity_mutation( } } if old.width_factor.to_bits() != new.width_factor.to_bits() - && (!new.width_factor.is_finite() || new.width_factor.abs() < 1e-12) { + && (!new.width_factor.is_finite() || new.width_factor.abs() < 1e-12) + { return Err("AttributeDefinition.width_factor must be finite and nonzero".into()); } if old.oblique_angle.to_bits() != new.oblique_angle.to_bits() - && new.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 { - return Err("AttributeDefinition.oblique_angle must be between -PI/2 and PI/2".into()); + && new.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 + { + return Err( + "AttributeDefinition.oblique_angle must be between -PI/2 and PI/2".into(), + ); } if old.text_style != new.text_style && new.text_style.trim().is_empty() { return Err("AttributeDefinition.text_style is empty".into()); @@ -334,6 +548,68 @@ pub fn validate_entity_mutation( return Err("AttributeDefinition.embedded_mtext is unmapped and read-only".into()); } } + (EntityType::AttributeEntity(old), EntityType::AttributeEntity(new)) => { + changed3( + "AttributeEntity.insertion_point", + &old.insertion_point, + &new.insertion_point, + )?; + changed3( + "AttributeEntity.alignment_point", + &old.alignment_point, + &new.alignment_point, + )?; + if !same3(&old.normal, &new.normal) { + solid_normal("AttributeEntity.normal", &new.normal)?; + } + if old.tag != new.tag + && (new.tag.trim().is_empty() || new.tag.chars().any(char::is_whitespace)) + { + return Err( + "AttributeEntity.tag must be nonempty and contain no whitespace".into(), + ); + } + if old.height.to_bits() != new.height.to_bits() + && (!new.height.is_finite() || new.height <= 0.0) + { + return Err("AttributeEntity.height must be finite and greater than zero".into()); + } + for (name, before, after) in [ + ("rotation", old.rotation, new.rotation), + ("oblique_angle", old.oblique_angle, new.oblique_angle), + ] { + if before.to_bits() != after.to_bits() && !after.is_finite() { + return Err(format!("AttributeEntity.{name} must be finite")); + } + } + if old.width_factor.to_bits() != new.width_factor.to_bits() + && (!new.width_factor.is_finite() || new.width_factor.abs() < 1e-12) + { + return Err("AttributeEntity.width_factor must be finite and nonzero".into()); + } + if old.oblique_angle.to_bits() != new.oblique_angle.to_bits() + && new.oblique_angle.abs() >= std::f64::consts::FRAC_PI_2 + { + return Err("AttributeEntity.oblique_angle must be between -PI/2 and PI/2".into()); + } + if old.text_style != new.text_style && new.text_style.trim().is_empty() { + return Err("AttributeEntity.text_style is empty".into()); + } + if old.field_length != new.field_length && new.field_length < 0 { + return Err("AttributeEntity.field_length must be nonnegative".into()); + } + if old.line_count != new.line_count && new.line_count < 1 { + return Err("AttributeEntity.line_count must be greater than zero".into()); + } + if old.embedded_mtext != new.embedded_mtext { + return Err("AttributeEntity.embedded_mtext is unmapped and read-only".into()); + } + if old.attdef_handle != new.attdef_handle + && !(old.tag != new.tag && !new.attdef_handle.is_null()) + { + return Err("AttributeEntity.attdef_handle is read-only".into()); + } + } _ => {} } Ok(()) @@ -349,7 +625,10 @@ pub fn validate_canvas_entity_references( use crate::host::EntityType; let owner = entity.common().owner_handle; if !owner.is_null() { - let block_owner = document.block_records.iter().any(|record| record.handle == owner); + let block_owner = document + .block_records + .iter() + .any(|record| record.handle == owner); let insert_owner = matches!(entity, EntityType::AttributeEntity(_)) && matches!(document.get_entity(owner), Some(EntityType::Insert(_))); if !block_owner && !insert_owner { @@ -357,31 +636,132 @@ pub fn validate_canvas_entity_references( } } if let EntityType::Tolerance(value) = entity { - let found = value.dimension_style_handle.filter(|handle| !handle.is_null()).map_or_else( - || document.dim_styles.iter().any(|style| style.name.eq_ignore_ascii_case(value.dimension_style_name.trim())), - |handle| document.dim_styles.iter().any(|style| style.handle == handle), + let found = value + .dimension_style_handle + .filter(|handle| !handle.is_null()) + .map_or_else( + || { + document.dim_styles.iter().any(|style| { + style + .name + .eq_ignore_ascii_case(value.dimension_style_name.trim()) + }) + }, + |handle| { + document + .dim_styles + .iter() + .any(|style| style.handle == handle) + }, ); - if !found { return Err(format!("Tolerance dimension style {:?} does not exist", value.dimension_style_name)); } + if !found { + return Err(format!( + "Tolerance dimension style {:?} does not exist", + value.dimension_style_name + )); + } } if let EntityType::Shape(value) = entity { - let style = value.style_handle.filter(|handle| !handle.is_null()) - .and_then(|handle| document.text_styles.iter().find(|style| style.handle == handle)) - .or_else(|| document.text_styles.iter().find(|style| style.name.eq_ignore_ascii_case(value.style_name.trim()))) + let style = value + .style_handle + .filter(|handle| !handle.is_null()) + .and_then(|handle| { + document + .text_styles + .iter() + .find(|style| style.handle == handle) + }) + .or_else(|| { + document + .text_styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(value.style_name.trim())) + }) .ok_or_else(|| format!("Shape text style {:?} does not exist", value.style_name))?; - if !style.is_shape_file { return Err(format!("Shape text style {:?} is not a shape-file style", style.name)); } - if style.font_file.trim().is_empty() { return Err(format!("Shape text style {:?} has no SHX file", style.name)); } + if !style.is_shape_file { + return Err(format!( + "Shape text style {:?} is not a shape-file style", + style.name + )); + } + if style.font_file.trim().is_empty() { + return Err(format!("Shape text style {:?} has no SHX file", style.name)); + } } if let EntityType::AttributeDefinition(value) = entity { let owner = entity.common().owner_handle; - let block = document.block_records.iter().find(|record| record.handle == owner) - .ok_or_else(|| "AttributeDefinition requires an existing block-record owner".to_owned())?; + let block = document + .block_records + .iter() + .find(|record| record.handle == owner) + .ok_or_else(|| { + "AttributeDefinition requires an existing block-record owner".to_owned() + })?; if block.is_model_space() || block.is_paper_space() { return Err("AttributeDefinition owner must be a block definition".into()); } - let style = document.text_styles.get(value.text_style.trim()) - .ok_or_else(|| format!("AttributeDefinition text style {:?} does not exist", value.text_style))?; + let style = document + .text_styles + .get(value.text_style.trim()) + .ok_or_else(|| { + format!( + "AttributeDefinition text style {:?} does not exist", + value.text_style + ) + })?; if style.is_shape_file { - return Err(format!("AttributeDefinition text style {:?} is a shape-file style", style.name)); + return Err(format!( + "AttributeDefinition text style {:?} is a shape-file style", + style.name + )); + } + } + if let EntityType::AttributeEntity(value) = entity { + let owner = entity.common().owner_handle; + let insert = match document.get_entity(owner) { + Some(EntityType::Insert(insert)) => insert, + _ => return Err("AttributeEntity requires an existing Insert owner".into()), + }; + let block = document + .block_records + .get(insert.block_name.trim()) + .ok_or_else(|| { + format!( + "AttributeEntity owner references missing block {:?}", + insert.block_name + ) + })?; + let definition = block + .entity_handles + .iter() + .find_map(|handle| match document.get_entity(*handle) { + Some(EntityType::AttributeDefinition(definition)) + if definition.common.handle == value.attdef_handle => + { + Some(definition) + } + _ => None, + }) + .ok_or_else(|| { + "AttributeEntity attdef_handle does not belong to the owner's block".to_owned() + })?; + if !definition.tag.eq_ignore_ascii_case(value.tag.trim()) { + return Err("AttributeEntity tag does not match its attribute definition".into()); + } + let style = document + .text_styles + .get(value.text_style.trim()) + .ok_or_else(|| { + format!( + "AttributeEntity text style {:?} does not exist", + value.text_style + ) + })?; + if style.is_shape_file { + return Err(format!( + "AttributeEntity text style {:?} is a shape-file style", + style.name + )); } } Ok(()) @@ -397,16 +777,69 @@ pub fn bind_canvas_entity_references( ) -> Result<(), String> { use crate::host::EntityType; match entity { - EntityType::Tolerance(value) if value.dimension_style_handle.filter(|h| !h.is_null()).is_none() => { - value.dimension_style_handle = document.dim_styles.iter() - .find(|style| style.name.eq_ignore_ascii_case(value.dimension_style_name.trim())) + EntityType::Tolerance(value) + if value + .dimension_style_handle + .filter(|h| !h.is_null()) + .is_none() => + { + value.dimension_style_handle = document + .dim_styles + .iter() + .find(|style| { + style + .name + .eq_ignore_ascii_case(value.dimension_style_name.trim()) + }) .map(|style| style.handle); } EntityType::Shape(value) if value.style_handle.filter(|h| !h.is_null()).is_none() => { - value.style_handle = document.text_styles.iter() + value.style_handle = document + .text_styles + .iter() .find(|style| style.name.eq_ignore_ascii_case(value.style_name.trim())) .map(|style| style.handle); } + EntityType::AttributeEntity(value) => { + let insert = match document.get_entity(value.common.owner_handle) { + Some(EntityType::Insert(insert)) => insert, + _ => return Err("AttributeEntity requires an existing Insert owner".into()), + }; + let block = document + .block_records + .get(insert.block_name.trim()) + .ok_or_else(|| { + format!( + "AttributeEntity owner references missing block {:?}", + insert.block_name + ) + })?; + let current_matches = block.entity_handles.iter().any(|handle| { + *handle == value.attdef_handle + && matches!(document.get_entity(*handle), + Some(EntityType::AttributeDefinition(definition)) + if definition.tag.eq_ignore_ascii_case(value.tag.trim())) + }); + if !current_matches { + value.attdef_handle = block + .entity_handles + .iter() + .find_map(|handle| match document.get_entity(*handle) { + Some(EntityType::AttributeDefinition(definition)) + if definition.tag.eq_ignore_ascii_case(value.tag.trim()) => + { + Some(*handle) + } + _ => None, + }) + .ok_or_else(|| { + format!( + "AttributeEntity tag {:?} has no definition in block {:?}", + value.tag, insert.block_name + ) + })?; + } + } _ => {} } validate_canvas_entity_references(document, entity) @@ -415,11 +848,22 @@ pub fn bind_canvas_entity_references( /// Clone a canvas entity with a new layer, preserving its other snapshot /// fields. Internal records and opaque fallbacks are excluded. #[cfg(feature = "host")] -pub fn patch_canvas_layer(entity: &crate::host::EntityType, layer: &str) -> Result { +pub fn patch_canvas_layer( + entity: &crate::host::EntityType, + layer: &str, +) -> Result { use crate::host::EntityType; - if layer.trim().is_empty() { return Err("layer name is empty".into()); } - if matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::Seqend(_) - | EntityType::Extended(_) | EntityType::Unknown(_)) { + if layer.trim().is_empty() { + return Err("layer name is empty".into()); + } + if matches!( + entity, + EntityType::Block(_) + | EntityType::BlockEnd(_) + | EntityType::Seqend(_) + | EntityType::Extended(_) + | EntityType::Unknown(_) + ) { return Err("entity kind does not support canvas layer edits".into()); } let mut changed = entity.clone(); @@ -434,23 +878,46 @@ mod tests { #[test] fn every_entity_variant_has_exactly_one_classification() { - let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); - let registry: TypeRegistry = serde_json::from_str(get_embedded_type_registry_json()).unwrap(); + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + let registry: TypeRegistry = + serde_json::from_str(get_embedded_type_registry_json()).unwrap(); let variants = ®istry.types[&TypeId::new("EntityType")].variants; assert_eq!(catalog.entity_kinds.len(), variants.len()); let mut names = std::collections::HashSet::new(); for entry in &catalog.entity_kinds { assert!(names.insert(&entry.kind), "duplicate kind {}", entry.kind); assert!(variants.iter().any(|v| v.name == entry.kind)); - assert!(entry.properties.iter().any(|p| p.name == "handle" && p.model_access == ModelAccess::ReadOnly)); - assert!(entry.properties.iter().any(|p| p.name == "kind" && p.model_access == ModelAccess::ReadOnly)); - assert!(entry.properties.iter().any(|p| p.name == "owner_handle" && p.model_access == ModelAccess::ReadOnly)); + assert!(entry + .properties + .iter() + .any(|p| p.name == "handle" && p.model_access == ModelAccess::ReadOnly)); + assert!(entry + .properties + .iter() + .any(|p| p.name == "kind" && p.model_access == ModelAccess::ReadOnly)); + assert!(entry + .properties + .iter() + .any(|p| p.name == "owner_handle" && p.model_access == ModelAccess::ReadOnly)); let mut source_paths = std::collections::HashSet::new(); for property in &entry.properties { - assert!(source_paths.insert(&property.source_path), "duplicate source path in {}: {}", entry.kind, property.source_path); - assert!(property.snapshot_readable, "unreadable traced property in {}: {}", entry.kind, property.name); + assert!( + source_paths.insert(&property.source_path), + "duplicate source path in {}: {}", + entry.kind, + property.source_path + ); + assert!( + property.snapshot_readable, + "unreadable traced property in {}: {}", + entry.kind, property.name + ); if property.model_access == ModelAccess::ReadWrite { - assert!(matches!(property.validation.as_str(), "type_conversion_only" | "transaction_geometry" | "transaction_nonempty")); + assert!(matches!( + property.validation.as_str(), + "type_conversion_only" | "transaction_geometry" | "transaction_nonempty" + )); } } } @@ -458,69 +925,156 @@ mod tests { #[test] fn unsupported_kinds_have_no_editable_properties() { - let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); for entry in &catalog.entity_kinds { - if entry.scope != EntityScope::Canvas || !matches!(entry.kind.as_str(), - "Point" | "Line" | "Circle" | "Arc" | "Ellipse" | "Polyline" | - "Polyline2D" | "Polyline3D" | "LwPolyline" | "Spline" | "Text" | "MText" | - "Ray" | "XLine" | "Solid" | "Face3D" | "Insert" | "Tolerance" | "Shape" | - "AttributeDefinition") { - assert!(entry.properties.iter().all(|p| p.model_access != ModelAccess::ReadWrite - || (entry.scope == EntityScope::Canvas && p.name == "layer")), "{}", entry.kind); + if entry.scope != EntityScope::Canvas + || !matches!( + entry.kind.as_str(), + "Point" + | "Line" + | "Circle" + | "Arc" + | "Ellipse" + | "Polyline" + | "Polyline2D" + | "Polyline3D" + | "LwPolyline" + | "Spline" + | "Text" + | "MText" + | "Ray" + | "XLine" + | "Solid" + | "Face3D" + | "Insert" + | "Tolerance" + | "Shape" + | "AttributeDefinition" + | "AttributeEntity" + ) + { + assert!( + entry + .properties + .iter() + .all(|p| p.model_access != ModelAccess::ReadWrite + || (entry.scope == EntityScope::Canvas && p.name == "layer")), + "{}", + entry.kind + ); } } } #[test] fn catalog_names_the_fields_with_transaction_geometry_checks() { - let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); - let checked: std::collections::HashSet<_> = catalog.entity_kinds.iter() - .flat_map(|entry| entry.properties.iter() + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + let checked: std::collections::HashSet<_> = catalog + .entity_kinds + .iter() + .flat_map(|entry| { + entry + .properties + .iter() .filter(|property| property.validation == "transaction_geometry") - .map(|property| format!("{}.{}", entry.kind, property.name))) + .map(|property| format!("{}.{}", entry.kind, property.name)) + }) .collect(); - assert_eq!(checked, std::collections::HashSet::from([ - "Point.location".to_owned(), "Line.start".to_owned(), "Line.end".to_owned(), - "Circle.center".to_owned(), "Circle.radius".to_owned(), - "Arc.center".to_owned(), "Arc.radius".to_owned(), - "Ray.base_point".to_owned(), "Ray.direction".to_owned(), - "XLine.base_point".to_owned(), "XLine.direction".to_owned(), - "Solid.first_corner".to_owned(), "Solid.second_corner".to_owned(), - "Solid.third_corner".to_owned(), "Solid.fourth_corner".to_owned(), - "Solid.normal".to_owned(), "Solid.thickness".to_owned(), - "Face3D.first_corner".to_owned(), "Face3D.second_corner".to_owned(), - "Face3D.third_corner".to_owned(), "Face3D.fourth_corner".to_owned(), + assert_eq!( + checked, + std::collections::HashSet::from([ + "Point.location".to_owned(), + "Line.start".to_owned(), + "Line.end".to_owned(), + "Circle.center".to_owned(), + "Circle.radius".to_owned(), + "Arc.center".to_owned(), + "Arc.radius".to_owned(), + "Ray.base_point".to_owned(), + "Ray.direction".to_owned(), + "XLine.base_point".to_owned(), + "XLine.direction".to_owned(), + "Solid.first_corner".to_owned(), + "Solid.second_corner".to_owned(), + "Solid.third_corner".to_owned(), + "Solid.fourth_corner".to_owned(), + "Solid.normal".to_owned(), + "Solid.thickness".to_owned(), + "Face3D.first_corner".to_owned(), + "Face3D.second_corner".to_owned(), + "Face3D.third_corner".to_owned(), + "Face3D.fourth_corner".to_owned(), "Face3D.invisible_edges".to_owned(), - "Insert.insert_point".to_owned(), "Insert.x_scale".to_owned(), - "Insert.y_scale".to_owned(), "Insert.z_scale".to_owned(), - "Insert.rotation".to_owned(), "Insert.normal".to_owned(), - "Insert.column_count".to_owned(), "Insert.row_count".to_owned(), - "Insert.column_spacing".to_owned(), "Insert.row_spacing".to_owned(), - "Tolerance.insertion_point".to_owned(), "Tolerance.direction".to_owned(), - "Tolerance.normal".to_owned(), "Tolerance.text".to_owned(), - "Tolerance.dimension_style_name".to_owned(), "Tolerance.text_height".to_owned(), + "Insert.insert_point".to_owned(), + "Insert.x_scale".to_owned(), + "Insert.y_scale".to_owned(), + "Insert.z_scale".to_owned(), + "Insert.rotation".to_owned(), + "Insert.normal".to_owned(), + "Insert.column_count".to_owned(), + "Insert.row_count".to_owned(), + "Insert.column_spacing".to_owned(), + "Insert.row_spacing".to_owned(), + "Tolerance.insertion_point".to_owned(), + "Tolerance.direction".to_owned(), + "Tolerance.normal".to_owned(), + "Tolerance.text".to_owned(), + "Tolerance.dimension_style_name".to_owned(), + "Tolerance.text_height".to_owned(), "Tolerance.dimension_gap".to_owned(), - "Shape.insertion_point".to_owned(), "Shape.size".to_owned(), - "Shape.shape_name".to_owned(), "Shape.shape_number".to_owned(), - "Shape.rotation".to_owned(), "Shape.relative_x_scale".to_owned(), - "Shape.oblique_angle".to_owned(), "Shape.normal".to_owned(), - "Shape.thickness".to_owned(), "Shape.style_name".to_owned(), - "AttributeDefinition.tag".to_owned(), "AttributeDefinition.prompt".to_owned(), + "Shape.insertion_point".to_owned(), + "Shape.size".to_owned(), + "Shape.shape_name".to_owned(), + "Shape.shape_number".to_owned(), + "Shape.rotation".to_owned(), + "Shape.relative_x_scale".to_owned(), + "Shape.oblique_angle".to_owned(), + "Shape.normal".to_owned(), + "Shape.thickness".to_owned(), + "Shape.style_name".to_owned(), + "AttributeDefinition.tag".to_owned(), + "AttributeDefinition.prompt".to_owned(), "AttributeDefinition.default_value".to_owned(), "AttributeDefinition.insertion_point".to_owned(), "AttributeDefinition.alignment_point".to_owned(), - "AttributeDefinition.height".to_owned(), "AttributeDefinition.rotation".to_owned(), + "AttributeDefinition.height".to_owned(), + "AttributeDefinition.rotation".to_owned(), "AttributeDefinition.width_factor".to_owned(), "AttributeDefinition.oblique_angle".to_owned(), "AttributeDefinition.text_style".to_owned(), "AttributeDefinition.text_generation_flags".to_owned(), "AttributeDefinition.horizontal_alignment".to_owned(), "AttributeDefinition.vertical_alignment".to_owned(), - "AttributeDefinition.flags".to_owned(), "AttributeDefinition.field_length".to_owned(), - "AttributeDefinition.normal".to_owned(), "AttributeDefinition.mtext_flag".to_owned(), - "AttributeDefinition.is_multiline".to_owned(), "AttributeDefinition.line_count".to_owned(), + "AttributeDefinition.flags".to_owned(), + "AttributeDefinition.field_length".to_owned(), + "AttributeDefinition.normal".to_owned(), + "AttributeDefinition.mtext_flag".to_owned(), + "AttributeDefinition.is_multiline".to_owned(), + "AttributeDefinition.line_count".to_owned(), "AttributeDefinition.lock_position".to_owned(), - ])); + "AttributeEntity.tag".to_owned(), + "AttributeEntity.value".to_owned(), + "AttributeEntity.insertion_point".to_owned(), + "AttributeEntity.alignment_point".to_owned(), + "AttributeEntity.height".to_owned(), + "AttributeEntity.rotation".to_owned(), + "AttributeEntity.width_factor".to_owned(), + "AttributeEntity.oblique_angle".to_owned(), + "AttributeEntity.text_style".to_owned(), + "AttributeEntity.text_generation_flags".to_owned(), + "AttributeEntity.horizontal_alignment".to_owned(), + "AttributeEntity.vertical_alignment".to_owned(), + "AttributeEntity.flags".to_owned(), + "AttributeEntity.field_length".to_owned(), + "AttributeEntity.normal".to_owned(), + "AttributeEntity.mtext_flag".to_owned(), + "AttributeEntity.is_multiline".to_owned(), + "AttributeEntity.line_count".to_owned(), + "AttributeEntity.lock_position".to_owned(), + ]) + ); } #[cfg(feature = "host")] @@ -529,18 +1083,26 @@ mod tests { use crate::host::acadrust::{self, types::Vector3}; let document = acadrust::CadDocument::new(); let mut tolerance = acadrust::entities::Tolerance::with_text( - Vector3::new(1.0, 2.0, 0.0), "{\\Fgdt;p}%%v0.1"); + Vector3::new(1.0, 2.0, 0.0), + "{\\Fgdt;p}%%v0.1", + ); let entity = acadrust::EntityType::Tolerance(tolerance.clone()); validate_new_canvas_entity(&entity).unwrap(); validate_canvas_entity_references(&document, &entity).unwrap(); tolerance.direction = Vector3::new(2.0, 0.0, 0.0); - assert!(validate_new_canvas_entity(&acadrust::EntityType::Tolerance(tolerance.clone())) - .unwrap_err().contains("unit vector")); + assert!( + validate_new_canvas_entity(&acadrust::EntityType::Tolerance(tolerance.clone())) + .unwrap_err() + .contains("unit vector") + ); tolerance.direction = Vector3::UNIT_X; tolerance.dimension_style_name = "Missing".into(); assert!(validate_canvas_entity_references( - &document, &acadrust::EntityType::Tolerance(tolerance)) - .unwrap_err().contains("does not exist")); + &document, + &acadrust::EntityType::Tolerance(tolerance) + ) + .unwrap_err() + .contains("does not exist")); } #[cfg(feature = "host")] @@ -555,26 +1117,51 @@ mod tests { let style_handle = style.handle; document.text_styles.add(style).unwrap(); let mut shape = acadrust::entities::Shape::with_style( - Vector3::new(1.0, 2.0, 0.0), "ARROW", "Symbols", 2.0, 0.5); + Vector3::new(1.0, 2.0, 0.0), + "ARROW", + "Symbols", + 2.0, + 0.5, + ); shape.shape_number = 1; let mut entity = acadrust::EntityType::Shape(shape); validate_new_canvas_entity(&entity).unwrap(); bind_canvas_entity_references(&document, &mut entity).unwrap(); assert!(matches!(entity, acadrust::EntityType::Shape(ref value) if value.style_handle == Some(style_handle))); - let acadrust::EntityType::Shape(mut bad) = entity else { unreachable!() }; + let acadrust::EntityType::Shape(mut bad) = entity else { + unreachable!() + }; bad.size = 0.0; - assert!(validate_new_canvas_entity(&acadrust::EntityType::Shape(bad)) - .unwrap_err().contains("greater than zero")); + assert!( + validate_new_canvas_entity(&acadrust::EntityType::Shape(bad)) + .unwrap_err() + .contains("greater than zero") + ); } #[test] fn layer_write_coverage_matches_canvas_scope() { - let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); - assert_eq!(catalog.entity_kinds.iter().filter(|entry| entry.scope == EntityScope::Canvas).count(), 43); + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + assert_eq!( + catalog + .entity_kinds + .iter() + .filter(|entry| entry.scope == EntityScope::Canvas) + .count(), + 43 + ); for entry in &catalog.entity_kinds { - let layer = entry.properties.iter().find(|property| property.name == "layer").unwrap(); - assert_eq!(layer.model_access == ModelAccess::ReadWrite, entry.scope == EntityScope::Canvas); + let layer = entry + .properties + .iter() + .find(|property| property.name == "layer") + .unwrap(); + assert_eq!( + layer.model_access == ModelAccess::ReadWrite, + entry.scope == EntityScope::Canvas + ); if entry.scope == EntityScope::Canvas { assert_eq!(layer.validation, "transaction_nonempty"); } @@ -583,13 +1170,38 @@ mod tests { #[test] fn insert_catalog_separates_transform_writes_from_block_identity() { - let catalog: EntityCoverageCatalog = serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); - let insert = catalog.entity_kinds.iter().find(|entry| entry.kind == "Insert").unwrap(); - let block_name = insert.properties.iter().find(|property| property.name == "block_name").unwrap(); + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + let insert = catalog + .entity_kinds + .iter() + .find(|entry| entry.kind == "Insert") + .unwrap(); + let block_name = insert + .properties + .iter() + .find(|property| property.name == "block_name") + .unwrap(); assert_eq!(block_name.model_access, ModelAccess::ReadOnly); assert_eq!(block_name.validation, "none"); - assert!(insert.properties.iter().find(|property| property.name == "attributes").unwrap().model_access == ModelAccess::Unmapped); - assert!(insert.properties.iter().find(|property| property.name == "insert_point").unwrap().model_access == ModelAccess::ReadWrite); + assert!( + insert + .properties + .iter() + .find(|property| property.name == "attributes") + .unwrap() + .model_access + == ModelAccess::Unmapped + ); + assert!( + insert + .properties + .iter() + .find(|property| property.name == "insert_point") + .unwrap() + .model_access + == ModelAccess::ReadWrite + ); } #[cfg(feature = "host")] @@ -598,7 +1210,10 @@ mod tests { use crate::host::acadrust; let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default()); let snapshot = entity_snapshot(&hatch).unwrap(); - let fields = snapshot.get("Hatch").and_then(|value| value.as_object()).unwrap(); + let fields = snapshot + .get("Hatch") + .and_then(|value| value.as_object()) + .unwrap(); assert!(fields.contains_key("common")); assert!(fields.len() > 1); } @@ -614,13 +1229,16 @@ mod tests { assert!(validate_entity_mutation( &acadrust::EntityType::Circle(before.clone()), &acadrust::EntityType::Circle(layer_only), - ).is_ok()); + ) + .is_ok()); let mut bad_radius = before.clone(); bad_radius.radius = -1.0; assert!(validate_entity_mutation( &acadrust::EntityType::Circle(before), &acadrust::EntityType::Circle(bad_radius), - ).unwrap_err().contains("Circle.radius")); + ) + .unwrap_err() + .contains("Circle.radius")); } #[cfg(feature = "host")] @@ -629,7 +1247,11 @@ mod tests { use crate::host::acadrust::{self, types::Vector3}; let mut ray = acadrust::entities::Ray::default(); ray.direction = Vector3::new(2.0, 0.0, 0.0); - assert!(validate_new_canvas_entity(&acadrust::EntityType::Ray(ray.clone())).unwrap_err().contains("Ray.direction")); + assert!( + validate_new_canvas_entity(&acadrust::EntityType::Ray(ray.clone())) + .unwrap_err() + .contains("Ray.direction") + ); let before = acadrust::EntityType::Ray(ray.clone()); ray.common.layer = "CONSTRUCTION".into(); assert!(validate_entity_mutation(&before, &acadrust::EntityType::Ray(ray.clone())).is_ok()); @@ -638,19 +1260,41 @@ mod tests { let mut xline = acadrust::entities::XLine::default(); xline.base_point.x = f64::NAN; - assert!(validate_new_canvas_entity(&acadrust::EntityType::XLine(xline)).unwrap_err().contains("XLine.base_point")); + assert!( + validate_new_canvas_entity(&acadrust::EntityType::XLine(xline)) + .unwrap_err() + .contains("XLine.base_point") + ); - let solid = acadrust::entities::Solid::new(Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, Vector3::ZERO); + let solid = acadrust::entities::Solid::new( + Vector3::ZERO, + Vector3::UNIT_X, + Vector3::UNIT_Y, + Vector3::ZERO, + ); let mut changed = solid.clone(); changed.thickness = f64::INFINITY; - assert!(validate_entity_mutation(&acadrust::EntityType::Solid(solid), &acadrust::EntityType::Solid(changed)) - .unwrap_err().contains("Solid.thickness")); + assert!(validate_entity_mutation( + &acadrust::EntityType::Solid(solid), + &acadrust::EntityType::Solid(changed) + ) + .unwrap_err() + .contains("Solid.thickness")); - let face = acadrust::entities::Face3D::new(Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, Vector3::ZERO); + let face = acadrust::entities::Face3D::new( + Vector3::ZERO, + Vector3::UNIT_X, + Vector3::UNIT_Y, + Vector3::ZERO, + ); let mut changed = face.clone(); changed.invisible_edges = acadrust::entities::InvisibleEdgeFlags::from_bits(0x10); - assert!(validate_entity_mutation(&acadrust::EntityType::Face3D(face), &acadrust::EntityType::Face3D(changed)) - .unwrap_err().contains("Face3D.invisible_edges")); + assert!(validate_entity_mutation( + &acadrust::EntityType::Face3D(face), + &acadrust::EntityType::Face3D(changed) + ) + .unwrap_err() + .contains("Face3D.invisible_edges")); } #[cfg(feature = "host")] @@ -661,20 +1305,35 @@ mod tests { let mut moved = before.clone(); moved.insert_point = Vector3::new(5.0, 6.0, 0.0); moved.set_x_scale(2.0); - assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before.clone()), - &acadrust::EntityType::Insert(moved)).is_ok()); + assert!(validate_entity_mutation( + &acadrust::EntityType::Insert(before.clone()), + &acadrust::EntityType::Insert(moved) + ) + .is_ok()); let mut bad = before.clone(); bad.set_y_scale(f64::NAN); - assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before.clone()), - &acadrust::EntityType::Insert(bad)).unwrap_err().contains("Insert.y_scale")); + assert!(validate_entity_mutation( + &acadrust::EntityType::Insert(before.clone()), + &acadrust::EntityType::Insert(bad) + ) + .unwrap_err() + .contains("Insert.y_scale")); let mut bad = before.clone(); bad.column_count = 0; - assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before.clone()), - &acadrust::EntityType::Insert(bad)).unwrap_err().contains("array counts")); + assert!(validate_entity_mutation( + &acadrust::EntityType::Insert(before.clone()), + &acadrust::EntityType::Insert(bad) + ) + .unwrap_err() + .contains("array counts")); let mut bad = before.clone(); bad.block_name = "OTHER".into(); - assert!(validate_entity_mutation(&acadrust::EntityType::Insert(before), - &acadrust::EntityType::Insert(bad)).unwrap_err().contains("block identity")); + assert!(validate_entity_mutation( + &acadrust::EntityType::Insert(before), + &acadrust::EntityType::Insert(bad) + ) + .unwrap_err() + .contains("block identity")); } #[cfg(feature = "host")] @@ -694,7 +1353,11 @@ mod tests { #[cfg(feature = "host")] #[test] fn attribute_definition_requires_a_block_owner_and_text_style() { - use crate::host::acadrust::{self, entities::{Block, BlockEnd}, types::{Handle, Vector3}}; + use crate::host::acadrust::{ + self, + entities::{Block, BlockEnd}, + types::{Handle, Vector3}, + }; let mut document = acadrust::CadDocument::new(); let next = document.next_handle(); let record_handle = Handle::new(next); @@ -708,14 +1371,21 @@ mod tests { let mut block = Block::new("TAGBLOCK", Vector3::ZERO); block.common.handle = block_handle; block.common.owner_handle = record_handle; - document.add_entity(acadrust::EntityType::Block(block)).unwrap(); + document + .add_entity(acadrust::EntityType::Block(block)) + .unwrap(); let mut end = BlockEnd::new(); end.common.handle = end_handle; end.common.owner_handle = record_handle; - document.add_entity(acadrust::EntityType::BlockEnd(end)).unwrap(); + document + .add_entity(acadrust::EntityType::BlockEnd(end)) + .unwrap(); let mut definition = acadrust::entities::AttributeDefinition::new( - "PART_NO".into(), "Part number".into(), "PN-001".into()); + "PART_NO".into(), + "Part number".into(), + "PN-001".into(), + ); definition.common.owner_handle = record_handle; definition.insertion_point = Vector3::new(1.0, 2.0, 0.0); let entity = acadrust::EntityType::AttributeDefinition(definition.clone()); @@ -724,16 +1394,25 @@ mod tests { definition.common.owner_handle = Handle::NULL; assert!(validate_canvas_entity_references( - &document, &acadrust::EntityType::AttributeDefinition(definition.clone())) - .unwrap_err().contains("block-record owner")); + &document, + &acadrust::EntityType::AttributeDefinition(definition.clone()) + ) + .unwrap_err() + .contains("block-record owner")); definition.common.owner_handle = record_handle; definition.text_style = "Missing".into(); assert!(validate_canvas_entity_references( - &document, &acadrust::EntityType::AttributeDefinition(definition.clone())) - .unwrap_err().contains("does not exist")); + &document, + &acadrust::EntityType::AttributeDefinition(definition.clone()) + ) + .unwrap_err() + .contains("does not exist")); definition.text_style = "Standard".into(); definition.tag = "BAD TAG".into(); - assert!(validate_new_canvas_entity(&acadrust::EntityType::AttributeDefinition(definition)) - .unwrap_err().contains("no whitespace")); + assert!( + validate_new_canvas_entity(&acadrust::EntityType::AttributeDefinition(definition)) + .unwrap_err() + .contains("no whitespace") + ); } } diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index ffa64e6e6..ee96ceac9 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -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::>(); + 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, @@ -77,9 +98,7 @@ pub fn handle_plugin_request( DocumentPath { tab_id } => PluginResponse::DocumentPath( host.document_path(tab_id).map(|path| path.into_os_string()), ), - GetSystemVariable { name } => { - PluginResponse::SystemVariable(host.system_variable(&name)) - } + GetSystemVariable { name } => PluginResponse::SystemVariable(host.system_variable(&name)), SetSystemVariable { name, value } => { PluginResponse::SystemVariableResult(host.set_system_variable(&name, value)) } diff --git a/crates/ocs_plugin_api/src/shm.rs b/crates/ocs_plugin_api/src/shm.rs index 4203ce406..5344737da 100644 --- a/crates/ocs_plugin_api/src/shm.rs +++ b/crates/ocs_plugin_api/src/shm.rs @@ -83,13 +83,19 @@ impl DocumentSnapshotStore { /// page. pub fn new(tab_id: u64, segment_size: usize) -> io::Result { 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 SharedDocumentReader { )); } 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(_))); } diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 17984346f..d60531410 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -7,9 +7,9 @@ use acadrust::xdata::ExtendedDataRecord; use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi, HostSettingValue}; use ocs_plugin_api::shm::{DocumentSnapshotStore, DocumentViewData}; +use super::OpenCADStudio; #[cfg(not(target_arch = "wasm32"))] use crate::plugin::v4_support; -use super::OpenCADStudio; /// Session adapter: one active document tab, command line, undo. pub(crate) struct HostSession<'a> { @@ -44,7 +44,10 @@ impl<'a> HostSession<'a> { } #[cfg(not(target_arch = "wasm32"))] - pub fn document_view_v4(&mut self, tab_id: u64) -> Option { + pub fn document_view_v4( + &mut self, + tab_id: u64, + ) -> Option { if tab_id != self.tab_id() { return None; } @@ -66,9 +69,52 @@ impl<'a> HostSession<'a> { &mut self.app.tabs[self.tab].scene.document } + fn nested_attribute(&self, handle: Handle) -> Option { + self.document().entities().find_map(|entity| { + let EntityType::Insert(insert) = entity else { + return None; + }; + insert + .attributes + .iter() + .find(|attribute| attribute.common.handle == handle) + .cloned() + .map(EntityType::AttributeEntity) + }) + } + + fn host_model_entity(&self, handle: Handle) -> Option { + self.document() + .get_entity(handle) + .cloned() + .or_else(|| self.nested_attribute(handle)) + } + + fn replace_nested_attribute(&mut self, attribute: acadrust::entities::AttributeEntity) -> bool { + let owner = attribute.common.owner_handle; + let handle = attribute.common.handle; + let Some(EntityType::Insert(mut insert)) = self.document().get_entity(owner).cloned() + else { + return false; + }; + let Some(slot) = insert + .attributes + .iter_mut() + .find(|candidate| candidate.common.handle == handle) + else { + return false; + }; + *slot = attribute; + self.app.tabs[self.tab] + .scene + .update_entity(EntityType::Insert(insert)) + } + pub fn document_view(&mut self) -> Option { if self.doc_store.is_none() { - let mut store = DocumentSnapshotStore::::new(self.tab as u64, 8 * 1024 * 1024).ok()?; + let mut store = + DocumentSnapshotStore::::new(self.tab as u64, 8 * 1024 * 1024) + .ok()?; store.publish(&self.document().into()).ok()?; self.doc_store = Some(store); } @@ -104,6 +150,33 @@ impl<'a> HostSession<'a> { } pub fn add_entity(&mut self, entity: EntityType) -> Handle { + if let EntityType::AttributeEntity(mut attribute) = entity { + let owner = attribute.common.owner_handle; + let Some(EntityType::Insert(mut insert)) = self.document().get_entity(owner).cloned() + else { + return Handle::NULL; + }; + if attribute.common.handle.is_null() { + attribute.common.handle = self.document_mut().allocate_handle(); + } + let handle = attribute.common.handle; + if insert + .attributes + .iter() + .any(|existing| existing.common.handle == handle) + { + return Handle::NULL; + } + insert.attributes.push(attribute); + if !self.app.tabs[self.tab] + .scene + .update_entity(EntityType::Insert(insert)) + { + return Handle::NULL; + } + self.publish_document_view(); + return handle; + } let handle = self.app.tabs[self.tab].scene.add_entity(entity); self.publish_document_view(); handle @@ -122,7 +195,10 @@ impl<'a> HostSession<'a> { /// Replace the entity carrying `entity`'s handle in place, refreshing the /// scene's derived caches. Returns `false` when no entity has that handle. pub fn update_entity(&mut self, entity: EntityType) -> bool { - let ok = self.app.tabs[self.tab].scene.update_entity(entity); + let ok = match entity { + EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), + entity => self.app.tabs[self.tab].scene.update_entity(entity), + }; if ok { self.publish_document_view(); } @@ -135,29 +211,37 @@ impl<'a> HostSession<'a> { mut entities: Vec, ) -> Result<(), String> { use std::collections::HashSet; - if label.trim().is_empty() { return Err("transaction label is empty".into()); } - if entities.is_empty() { return Ok(()); } + if label.trim().is_empty() { + return Err("transaction label is empty".into()); + } + if entities.is_empty() { + return Ok(()); + } let mut seen = HashSet::new(); for entity in &mut entities { let handle = entity.common().handle; if handle.is_null() || !seen.insert(handle) { return Err(format!("null or duplicate entity handle: {handle:?}")); } - let Some(existing) = self.document().get_entity(handle) else { + let Some(existing) = self.host_model_entity(handle) else { return Err(format!("entity {handle:?} does not exist")); }; - if std::mem::discriminant(existing) != std::mem::discriminant(entity) { + if std::mem::discriminant(&existing) != std::mem::discriminant(entity) { return Err(format!("entity {handle:?} changes kind")); } if existing.common().owner_handle != entity.common().owner_handle { return Err(format!("entity {handle:?} changes owner")); } - ocs_plugin_api::entity_coverage::bind_canvas_entity_references( - self.document(), entity, - ).map_err(|error| format!("entity {handle:?}: {error}"))?; - ocs_plugin_api::entity_coverage::validate_entity_mutation(existing, entity) + ocs_plugin_api::entity_coverage::bind_canvas_entity_references(self.document(), entity) .map_err(|error| format!("entity {handle:?}: {error}"))?; - if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + ocs_plugin_api::entity_coverage::validate_entity_mutation(&existing, entity) + .map_err(|error| format!("entity {handle:?}: {error}"))?; + let lock_handle = if matches!(entity, EntityType::AttributeEntity(_)) { + entity.common().owner_handle + } else { + handle + }; + if self.app.tabs[self.tab].scene.is_layer_locked(lock_handle) { return Err(format!("entity {handle:?} is on a locked layer")); } } @@ -165,7 +249,11 @@ impl<'a> HostSession<'a> { for entity in entities { // Validation above makes this infallible while the session owns // the document exclusively. - assert!(self.app.tabs[self.tab].scene.update_entity(entity)); + let updated = match entity { + EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), + entity => self.app.tabs[self.tab].scene.update_entity(entity), + }; + assert!(updated); } self.set_dirty(); self.publish_document_view(); @@ -186,15 +274,23 @@ impl<'a> HostSession<'a> { return Err(format!("entity {handle:?} does not exist")); } } - if self.selection() == handles { return Ok(()); } + if self.selection() == handles { + return Ok(()); + } let scene = &mut self.app.tabs[self.tab].scene; scene.replace_selection_exact(handles); #[cfg(not(target_arch = "wasm32"))] { if self.tab == self.app.active_tab { - self.app.last_plugin_selection = Some((self.tab_id(), self.app.tabs[self.tab].scene.selection_fingerprint())); + self.app.last_plugin_selection = Some(( + self.tab_id(), + self.app.tabs[self.tab].scene.selection_fingerprint(), + )); } - crate::plugin::v4_support::publish_selection_changed_v4(self.tab_id(), self.selection()); + crate::plugin::v4_support::publish_selection_changed_v4( + self.tab_id(), + self.selection(), + ); } Ok(()) } @@ -204,7 +300,24 @@ impl<'a> HostSession<'a> { /// (which `erase_entities` refuses to remove). pub fn remove_entity(&mut self, handle: Handle) -> bool { if self.document().get_entity(handle).is_none() { + let Some(EntityType::AttributeEntity(attribute)) = self.nested_attribute(handle) else { return false; + }; + let owner = attribute.common.owner_handle; + let Some(EntityType::Insert(mut insert)) = self.document().get_entity(owner).cloned() + else { + return false; + }; + insert + .attributes + .retain(|candidate| candidate.common.handle != handle); + let removed = self.app.tabs[self.tab] + .scene + .update_entity(EntityType::Insert(insert)); + if removed { + self.publish_document_view(); + } + return removed; } self.app.tabs[self.tab].scene.erase_entities(&[handle]); let removed = self.document().get_entity(handle).is_none(); @@ -274,7 +387,11 @@ impl<'a> HostSession<'a> { if self.app.tabs[self.tab].scene.is_layer_locked(handle) { return false; } - let app_handle = self.document().app_ids.get(app_name).map(|a| a.handle.value()); + let app_handle = self + .document() + .app_ids + .get(app_name) + .map(|a| a.handle.value()); let Some(entity) = self.document_mut().get_entity_mut(handle) else { return false; }; @@ -355,10 +472,9 @@ impl<'a> HostSession<'a> { ("SNAPANG", HostSettingValue::Number(_)) => { Err("SNAPANG: finite number required".to_owned()) } - ("CLAYER", _) | ("SNAPANG", _) => Err(format!( - "{}: wrong value type", - name.to_ascii_uppercase() - )), + ("CLAYER", _) | ("SNAPANG", _) => { + Err(format!("{}: wrong value type", name.to_ascii_uppercase())) + } _ => Err(format!("unsupported system variable {name:?}")), } } @@ -400,11 +516,19 @@ impl HostApi for HostSession<'_> { fn update_entity(&mut self, entity: EntityType) -> bool { self.update_entity(entity) } - fn update_entities_transaction(&mut self, label: &str, entities: Vec) -> Result<(), String> { + fn update_entities_transaction( + &mut self, + label: &str, + entities: Vec, + ) -> Result<(), String> { self.update_entities_transaction(label, entities) } - fn selection(&self) -> Vec { self.selection() } - fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { self.set_selection(handles) } + fn selection(&self) -> Vec { + self.selection() + } + fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { + self.set_selection(handles) + } fn remove_entity(&mut self, handle: Handle) -> bool { self.remove_entity(handle) } @@ -589,10 +713,7 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter { use ocs_plugin_api::ipc::protocol::InteractiveEvent; let result = self .process - .interactive_event( - self.command_id, - InteractiveEvent::Point([pt.x, pt.y, pt.z]), - ) + .interactive_event(self.command_id, InteractiveEvent::Point([pt.x, pt.y, pt.z])) .map(plugin_step_to_result) .unwrap_or(crate::command::CmdResult::Cancel); self.refresh(); @@ -666,6 +787,114 @@ mod tests { assert!(host.selection().is_empty()); } + #[test] + fn nested_insert_attribute_crud_uses_parent_storage_and_undo() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let (insert_handle, attribute_handle); + { + let mut host = HostSession::new(&mut app, 0); + let next = host.document().next_handle(); + let record_handle = Handle::new(next); + let block_handle = Handle::new(next + 1); + let block_end_handle = Handle::new(next + 2); + let mut record = acadrust::tables::BlockRecord::new("TAGBLOCK"); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = block_end_handle; + host.document_mut().block_records.add(record).unwrap(); + let mut block = + acadrust::entities::Block::new("TAGBLOCK", acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + host.document_mut() + .add_entity(EntityType::Block(block)) + .unwrap(); + let mut end = acadrust::entities::BlockEnd::new(); + end.common.handle = block_end_handle; + end.common.owner_handle = record_handle; + host.document_mut() + .add_entity(EntityType::BlockEnd(end)) + .unwrap(); + let mut definition = acadrust::entities::AttributeDefinition::new( + "PART_NO".into(), + "Part number".into(), + "PN-001".into(), + ); + definition.common.owner_handle = record_handle; + let definition_handle = host + .document_mut() + .add_entity(EntityType::AttributeDefinition(definition)) + .unwrap(); + insert_handle = host + .document_mut() + .add_entity(EntityType::Insert(acadrust::entities::Insert::new( + "TAGBLOCK", + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ))) + .unwrap(); + + let mut attribute = + acadrust::entities::AttributeEntity::new("PART_NO".into(), "PN-101".into()); + attribute.common.owner_handle = insert_handle; + attribute.attdef_handle = definition_handle; + attribute.insertion_point = acadrust::types::Vector3::new(10.0, 2.0, 0.0); + host.push_undo("Create attribute"); + attribute_handle = host.add_entity(EntityType::AttributeEntity(attribute)); + assert!(!attribute_handle.is_null()); + assert!( + host.document().get_entity(attribute_handle).is_none(), + "nested attributes must not become orphan flat entities" + ); + assert!(matches!(host.host_model_entity(attribute_handle), + Some(EntityType::AttributeEntity(value)) if value.value == "PN-101")); + let view = ocs_plugin_api::shm::DocumentViewDataV4::from(host.document()); + assert!(view + .entities + .iter() + .any(|entity| entity.handle == attribute_handle.value())); + + let mut changed = host.host_model_entity(attribute_handle).unwrap(); + let EntityType::AttributeEntity(value) = &mut changed else { + unreachable!() + }; + value.value = "PN-102".into(); + value.insertion_point.x = 12.0; + host.update_entities_transaction("Edit attribute", vec![changed]) + .unwrap(); + assert!(matches!(host.host_model_entity(attribute_handle), + Some(EntityType::AttributeEntity(value)) + if value.value == "PN-102" && value.insertion_point.x == 12.0)); + + host.push_undo("Delete attribute"); + assert!(host.remove_entity(attribute_handle)); + assert!(host.host_model_entity(attribute_handle).is_none()); + } + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.len() == 1 + && insert.attributes[0].value == "PN-102") + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes[0].value == "PN-101") + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.is_empty()) + ); + app.redo_steps(3); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.is_empty()) + ); + } + #[test] fn scripted_selection_does_not_expand_linked_leader_annotation() { let mut app = OpenCADStudio::new_for_test(); @@ -686,32 +915,59 @@ mod tests { let (first, second); { let mut host = HostSession::new(&mut app, 0); - first = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(1.0, 0.0, 0.0)))); - second = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new(2.0, 0.0, 0.0)))); + first = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new( + 1.0, 0.0, 0.0, + )))); + second = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new( + 2.0, 0.0, 0.0, + )))); let mut a = host.document().get_entity(first).unwrap().clone(); let mut b = host.document().get_entity(second).unwrap().clone(); - if let EntityType::Point(p) = &mut a { p.location.x = 10.0; } - if let EntityType::Point(p) = &mut b { p.location.x = 20.0; } + if let EntityType::Point(p) = &mut a { + p.location.x = 10.0; + } + if let EntityType::Point(p) = &mut b { + p.location.x = 20.0; + } let mut invalid = b.clone(); invalid.common_mut().owner_handle = Handle::new(99999); - assert!(host.update_entities_transaction("Move points", vec![a.clone(), invalid]).is_err()); + assert!(host + .update_entities_transaction("Move points", vec![a.clone(), invalid]) + .is_err()); let mut invalid_geometry = b.clone(); - if let EntityType::Point(p) = &mut invalid_geometry { p.location.x = f64::NAN; } - assert!(host.update_entities_transaction("Move points", vec![a.clone(), invalid_geometry]) - .unwrap_err().contains("Point.location")); + if let EntityType::Point(p) = &mut invalid_geometry { + p.location.x = f64::NAN; + } + assert!(host + .update_entities_transaction("Move points", vec![a.clone(), invalid_geometry]) + .unwrap_err() + .contains("Point.location")); assert_eq!(host.app.tabs[0].history.undo_stack.len(), 0); - assert!(matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0)); - host.update_entities_transaction("Move points", vec![a, b]).unwrap(); - assert!(matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0)); + assert!( + matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0) + ); + host.update_entities_transaction("Move points", vec![a, b]) + .unwrap(); + assert!( + matches!(host.document().get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0) + ); } app.finish_pending_history(0); assert_eq!(app.tabs[0].history.undo_stack.len(), 1); app.undo_steps(1); - assert!(matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0)); - assert!(matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 2.0)); + assert!( + matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 1.0) + ); + assert!( + matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 2.0) + ); app.redo_steps(1); - assert!(matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0)); - assert!(matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 20.0)); + assert!( + matches!(app.tabs[0].scene.document.get_entity(first), Some(EntityType::Point(p)) if p.location.x == 10.0) + ); + assert!( + matches!(app.tabs[0].scene.document.get_entity(second), Some(EntityType::Point(p)) if p.location.x == 20.0) + ); } /// Opt-in end-to-end check using the staged Python cdylib and the actual @@ -720,9 +976,15 @@ mod tests { /// this executes Python and calls the app's HostSession over real IPC. #[test] fn staged_python_plugin_line_lifecycle_over_real_ipc() { - let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; let plugin_path = std::path::PathBuf::from(plugin_path); - assert!(plugin_path.is_file(), "missing staged Python plugin: {}", plugin_path.display()); + assert!( + plugin_path.is_file(), + "missing staged Python plugin: {}", + plugin_path.display() + ); let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); assert!(std::path::Path::new(&runner_path).is_file()); @@ -731,63 +993,120 @@ mod tests { app.tabs[0].is_start = false; let mut host = HostSession::new(&mut app, 0); let process = ocs_plugin_api::process::PluginProcess::spawn( - &plugin_path, &mut host, crate::plugin::v4_support::notification_handler(), - ).expect("spawn staged Python plugin"); + &plugin_path, + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .expect("spawn staged Python plugin"); assert_eq!(process.id(), "opencad.python"); let dispatch = |host: &mut HostSession<'_>, command: &str| { - assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); }; - dispatch(&mut host, concat!( + dispatch( + &mut host, + concat!( "PY_EVAL ocs.active_document.create_entity('Line',", "start={'x':0.0,'y':0.0,'z':0.0},", "end={'x':1.0,'y':0.0,'z':0.0}).handle" - )); - let handles: Vec<_> = host.document().entities().map(|entity| entity.common().handle).collect(); + ), + ); + let handles: Vec<_> = host + .document() + .entities() + .map(|entity| entity.common().handle) + .collect(); assert_eq!(handles.len(), 1, "Python add did not reach the host"); let handle = handles[0]; - assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0)); + assert!( + matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0) + ); let before_edit = host.document().get_entity(handle).unwrap().clone(); dispatch(&mut host, &format!( "PY_EVAL ocs.update_many('Move line', [{{'handle':{},'end':{{'x':5.0,'y':0.0,'z':0.0}}}}])", handle.value() )); let mut expected_after_edit = before_edit; - let EntityType::Line(expected_line) = &mut expected_after_edit else { unreachable!() }; + let EntityType::Line(expected_line) = &mut expected_after_edit else { + unreachable!() + }; expected_line.end.x = 5.0; - assert_eq!(host.document().get_entity(handle), Some(&expected_after_edit), - "partial Python edit must preserve every unmentioned common and geometry field"); + assert_eq!( + host.document().get_entity(handle), + Some(&expected_after_edit), + "partial Python edit must preserve every unmentioned common and geometry field" + ); dispatch(&mut host, &format!( "PY_EVAL ocs.update_many('Reject NaN', [{{'handle':{},'start':{{'x':float('nan'),'y':0.0,'z':0.0}}}}])", handle.value() )); - assert_eq!(host.document().get_entity(handle), Some(&expected_after_edit), - "invalid geometry changed the line"); - assert!(host.app.command_line.history.last().unwrap().text.contains("finite coordinates")); + assert_eq!( + host.document().get_entity(handle), + Some(&expected_after_edit), + "invalid geometry changed the line" + ); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("finite coordinates")); dispatch(&mut host, &format!( "PY_EVAL ocs.update_many('Reject duplicate', [{{'handle':{},'end':{{'x':8.0,'y':0.0,'z':0.0}}}},{{'handle':{},'end':{{'x':9.0,'y':0.0,'z':0.0}}}}])", handle.value(), handle.value() )); - assert!(matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0), - "rejected batch changed the line"); - let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).expect("write edited DWG"); + assert!( + matches!(host.document().get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0), + "rejected batch changed the line" + ); + let dwg_bytes = + acadrust::DwgWriter::write_to_vec(host.document()).expect("write edited DWG"); let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes)) - .read().expect("reopen edited DWG"); - assert!(matches!(dwg_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)); - let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().expect("write edited DXF"); + .read() + .expect("reopen edited DWG"); + assert!( + matches!(dwg_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0) + ); + let dxf_bytes = acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .expect("write edited DXF"); let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes)) - .expect("open edited DXF").read().expect("reopen edited DXF"); - assert!(matches!(dxf_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)); - dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); - assert!(host.document().get_entity(handle).is_none(), "Python removal did not reach the host"); + .expect("open edited DXF") + .read() + .expect("reopen edited DXF"); + assert!( + matches!(dxf_doc.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0) + ); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.delete_entity({})", + handle.value() + ), + ); + assert!( + host.document().get_entity(handle).is_none(), + "Python removal did not reach the host" + ); drop(process); drop(host); app.finish_pending_history(0); - assert_eq!(app.tabs[0].history.undo_stack.len(), 3, - "create, edit, and delete should each form one undo entry"); + assert_eq!( + app.tabs[0].history.undo_stack.len(), + 3, + "create, edit, and delete should each form one undo entry" + ); app.undo_steps(1); - assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0)); + assert!( + matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 5.0) + ); app.undo_steps(1); - assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0)); + assert!( + matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Line(line)) if line.end.x == 1.0) + ); app.undo_steps(1); assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); app.redo_steps(3); @@ -796,7 +1115,9 @@ mod tests { #[test] fn staged_python_tolerance_lifecycle_over_real_ipc() { - let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); assert!(std::path::Path::new(&runner_path).is_file()); @@ -804,32 +1125,64 @@ mod tests { app.tabs[0].is_start = false; let mut host = HostSession::new(&mut app, 0); let process = ocs_plugin_api::process::PluginProcess::spawn( - std::path::Path::new(&plugin_path), &mut host, + std::path::Path::new(&plugin_path), + &mut host, crate::plugin::v4_support::notification_handler(), - ).expect("spawn staged Python plugin"); + ) + .expect("spawn staged Python plugin"); let dispatch = |host: &mut HostSession<'_>, command: &str| { - assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); }; - dispatch(&mut host, concat!( + dispatch( + &mut host, + concat!( "PY_EVAL ocs.active_document.create_entity('Tolerance',", "insertion_point={'x':1.0,'y':2.0,'z':0.0},", "text='POSITION%%v0.1').handle" - )); - let handle = host.document().entities().next().expect("Python created a Tolerance") - .common().handle; + ), + ); + let handle = host + .document() + .entities() + .next() + .expect("Python created a Tolerance") + .common() + .handle; let Some(EntityType::Tolerance(created)) = host.document().get_entity(handle) else { panic!("expected Tolerance"); }; - assert_eq!(created.insertion_point, acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + assert_eq!( + created.insertion_point, + acadrust::types::Vector3::new(1.0, 2.0, 0.0) + ); assert_eq!(created.dimension_style_name, "Standard"); let created = created.clone(); - dispatch(&mut host, &format!( - "PY_EVAL ocs.active_document.entities[{}].text", handle.value())); - assert!(host.app.command_line.history.last().unwrap().text.contains("POSITION%%v0.1")); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.entities[{}].text", + handle.value() + ), + ); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("POSITION%%v0.1")); let script_path = std::env::temp_dir().join(format!( - "ocs_tolerance_document_model_{}.py", std::process::id())); - std::fs::write(&script_path, format!(concat!( + "ocs_tolerance_document_model_{}.py", + std::process::id() + )); + std::fs::write( + &script_path, + format!( + concat!( "doc = ocs.active_document\n", "tolerance = doc.entities[{}]\n", "with doc.transaction('Edit tolerance'):\n", @@ -837,55 +1190,106 @@ mod tests { " tolerance.direction = (0.0, 1.0, 0.0)\n", " tolerance.text = 'POSITION%%v0.2'\n", "doc.selection = [tolerance]\n" - ), handle.value())).unwrap(); + ), + handle.value() + ), + ) + .unwrap(); dispatch(&mut host, &format!("PY_RUN {}", script_path.display())); let _ = std::fs::remove_file(&script_path); let expected = host.document().get_entity(handle).unwrap().clone(); - let EntityType::Tolerance(edited) = &expected else { unreachable!() }; - assert_eq!(edited.insertion_point, acadrust::types::Vector3::new(4.0, 5.0, 0.0)); + let EntityType::Tolerance(edited) = &expected else { + unreachable!() + }; + assert_eq!( + edited.insertion_point, + acadrust::types::Vector3::new(4.0, 5.0, 0.0) + ); assert_eq!(edited.direction, acadrust::types::Vector3::UNIT_Y); assert_eq!(edited.text, "POSITION%%v0.2"); assert_eq!(edited.common, created.common); assert_eq!(edited.normal, created.normal); assert_eq!(edited.dimension_style_name, created.dimension_style_name); - assert_eq!(edited.dimension_style_handle, created.dimension_style_handle); + assert_eq!( + edited.dimension_style_handle, + created.dimension_style_handle + ); assert_eq!(edited.text_height, created.text_height); assert_eq!(edited.dimension_gap, created.dimension_gap); assert_eq!(edited.dwg_unknown_short, created.dwg_unknown_short); - assert_eq!(host.selection(), vec![handle], "Python selection did not reach the canvas"); + assert_eq!( + host.selection(), + vec![handle], + "Python selection did not reach the canvas" + ); dispatch(&mut host, &format!( "PY_EVAL ocs.update_many('Reject direction', [{{'handle':{},'direction':{{'x':2.0,'y':0.0,'z':0.0}}}}])", handle.value())); assert_eq!(host.document().get_entity(handle), Some(&expected)); - assert!(host.app.command_line.history.last().unwrap().text.contains("unit vector")); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("unit vector")); dispatch(&mut host, &format!( "PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'dimension_style_name':'Missing'}}])", handle.value())); assert_eq!(host.document().get_entity(handle), Some(&expected)); - assert!(host.app.command_line.history.last().unwrap().text.contains("does not exist")); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("does not exist")); let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); - let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes)).read().unwrap(); - assert!(matches!(dwg_doc.get_entity(handle), Some(EntityType::Tolerance(value)) - if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2")); - let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap(); + let dwg_doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(dwg_bytes)) + .read() + .unwrap(); + assert!( + matches!(dwg_doc.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2") + ); + let dxf_bytes = acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); let dxf_doc = acadrust::DxfReader::from_reader(std::io::Cursor::new(dxf_bytes)) - .unwrap().read().unwrap(); - assert!(matches!(dxf_doc.get_entity(handle), Some(EntityType::Tolerance(value)) - if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2")); + .unwrap() + .read() + .unwrap(); + assert!( + matches!(dxf_doc.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 4.0 && value.text == "POSITION%%v0.2") + ); - dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.delete_entity({})", + handle.value() + ), + ); assert!(host.document().get_entity(handle).is_none()); drop(process); drop(host); app.finish_pending_history(0); assert_eq!(app.tabs[0].history.undo_stack.len(), 3); app.undo_steps(1); - assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + assert_eq!( + app.tabs[0].scene.document.get_entity(handle), + Some(&expected) + ); app.undo_steps(1); - assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Tolerance(value)) - if value.insertion_point.x == 1.0 && value.text == "POSITION%%v0.1")); + assert!( + matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Tolerance(value)) + if value.insertion_point.x == 1.0 && value.text == "POSITION%%v0.1") + ); app.undo_steps(1); assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); app.redo_steps(3); @@ -894,14 +1298,19 @@ mod tests { #[test] fn staged_python_shape_lifecycle_over_real_ipc() { - let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); assert!(std::path::Path::new(&runner_path).is_file()); - let shx_path = std::env::temp_dir().join(format!("ocs_host_shape_{}.shx", std::process::id())); + let shx_path = + std::env::temp_dir().join(format!("ocs_host_shape_{}.shx", std::process::id())); let mut shx = b"AutoCAD-86 shapes 1.0\r\n\x1A".to_vec(); - for value in [1_u16, 1, 1, 1, 9] { shx.extend_from_slice(&value.to_le_bytes()); } + for value in [1_u16, 1, 1, 1, 9] { + shx.extend_from_slice(&value.to_le_bytes()); + } shx.extend_from_slice(b"ARROW\0\x10\x14\0"); std::fs::write(&shx_path, shx).unwrap(); @@ -916,31 +1325,60 @@ mod tests { host.document_mut().text_styles.add(style).unwrap(); let process = ocs_plugin_api::process::PluginProcess::spawn( - std::path::Path::new(&plugin_path), &mut host, + std::path::Path::new(&plugin_path), + &mut host, crate::plugin::v4_support::notification_handler(), - ).expect("spawn staged Python plugin"); + ) + .expect("spawn staged Python plugin"); let dispatch = |host: &mut HostSession<'_>, command: &str| { - assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); }; - dispatch(&mut host, concat!( + dispatch( + &mut host, + concat!( "PY_EVAL ocs.active_document.create_entity('Shape',", "insertion_point={'x':1.0,'y':2.0,'z':0.0},size=2.0,", "shape_name='ARROW',shape_number=1,style_name='TestShapes').handle" - )); - let handle = host.document().entities().next().expect("Python created a Shape") - .common().handle; + ), + ); + let handle = host + .document() + .entities() + .next() + .expect("Python created a Shape") + .common() + .handle; let Some(EntityType::Shape(created)) = host.document().get_entity(handle) else { panic!("expected Shape"); }; assert_eq!(created.style_handle, Some(style_handle)); let created = created.clone(); - dispatch(&mut host, &format!( - "PY_EVAL ocs.active_document.entities[{}].shape_name", handle.value())); - assert!(host.app.command_line.history.last().unwrap().text.contains("ARROW")); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.entities[{}].shape_name", + handle.value() + ), + ); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("ARROW")); let script_path = std::env::temp_dir().join(format!( - "ocs_shape_document_model_{}.py", std::process::id())); - std::fs::write(&script_path, format!(concat!( + "ocs_shape_document_model_{}.py", + std::process::id() + )); + std::fs::write( + &script_path, + format!( + concat!( "doc = ocs.active_document\n", "shape = doc.entities[{}]\n", "with doc.transaction('Edit shape'):\n", @@ -949,12 +1387,21 @@ mod tests { " shape.rotation = 0.5\n", " shape.relative_x_scale = 1.5\n", "doc.selection = [shape]\n" - ), handle.value())).unwrap(); + ), + handle.value() + ), + ) + .unwrap(); dispatch(&mut host, &format!("PY_RUN {}", script_path.display())); let _ = std::fs::remove_file(&script_path); let expected = host.document().get_entity(handle).unwrap().clone(); - let EntityType::Shape(edited) = &expected else { unreachable!() }; - assert_eq!(edited.insertion_point, acadrust::types::Vector3::new(4.0, 5.0, 0.0)); + let EntityType::Shape(edited) = &expected else { + unreachable!() + }; + assert_eq!( + edited.insertion_point, + acadrust::types::Vector3::new(4.0, 5.0, 0.0) + ); assert_eq!(edited.size, 3.0); assert_eq!(edited.rotation, 0.5); assert_eq!(edited.relative_x_scale, 1.5); @@ -968,25 +1415,47 @@ mod tests { assert_eq!(host.selection(), vec![handle]); let assert_real_glyph = |entity: &EntityType, document: &CadDocument| { - let EntityType::Shape(shape) = entity else { panic!("expected Shape"); }; + let EntityType::Shape(shape) = entity else { + panic!("expected Shape"); + }; let render = shape.to_render(document).expect("shape render"); - let crate::scene::convert::acad_to_render::RenderObject::Lines(points) = render.object else { + let crate::scene::convert::acad_to_render::RenderObject::Lines(points) = render.object + else { panic!("expected shape linework"); }; - assert_eq!(points.len(), 3, "expected SHX glyph rather than diamond placeholder"); + assert_eq!( + points.len(), + 3, + "expected SHX glyph rather than diamond placeholder" + ); assert!(points.iter().flatten().all(|value| value.is_finite())); }; assert_real_glyph(&expected, host.document()); - dispatch(&mut host, &format!( + dispatch( + &mut host, + &format!( "PY_EVAL ocs.update_many('Reject size', [{{'handle':{},'size':0.0}}])", - handle.value())); + handle.value() + ), + ); assert_eq!(host.document().get_entity(handle), Some(&expected)); - dispatch(&mut host, &format!( + dispatch( + &mut host, + &format!( "PY_EVAL ocs.update_many('Reject style', [{{'handle':{},'style_name':'Missing'}}])", - handle.value())); + handle.value() + ), + ); assert_eq!(host.document().get_entity(handle), Some(&expected)); - assert!(host.app.command_line.history.last().unwrap().text.contains("does not exist")); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains("does not exist")); let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); let dwg_doc = crate::io::load_bytes("shape.dwg", dwg_bytes).unwrap(); @@ -994,14 +1463,22 @@ mod tests { assert!(matches!(dwg_entity, EntityType::Shape(value) if value.shape_number == 1 && value.size == 3.0 && value.rotation == 0.5)); assert_real_glyph(dwg_entity, &dwg_doc); - let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap(); + let dxf_bytes = acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); let dxf_doc = crate::io::load_bytes("shape.dxf", dxf_bytes).unwrap(); let dxf_entity = dxf_doc.get_entity(handle).expect("Shape survives DXF"); assert!(matches!(dxf_entity, EntityType::Shape(value) if value.shape_name == "ARROW" && value.size == 3.0 && (value.rotation - 0.5).abs() < 1e-12)); assert_real_glyph(dxf_entity, &dxf_doc); - dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.delete_entity({})", + handle.value() + ), + ); assert!(host.document().get_entity(handle).is_none()); drop(process); drop(host); @@ -1009,10 +1486,15 @@ mod tests { app.finish_pending_history(0); assert_eq!(app.tabs[0].history.undo_stack.len(), 3); app.undo_steps(1); - assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + assert_eq!( + app.tabs[0].scene.document.get_entity(handle), + Some(&expected) + ); app.undo_steps(1); - assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Shape(value)) - if value.insertion_point.x == 1.0 && value.size == 2.0)); + assert!( + matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Shape(value)) + if value.insertion_point.x == 1.0 && value.size == 2.0) + ); app.undo_steps(1); assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); app.redo_steps(3); @@ -1021,7 +1503,9 @@ mod tests { #[test] fn staged_python_attribute_definition_lifecycle_over_real_ipc() { - let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); assert!(std::path::Path::new(&runner_path).is_file()); @@ -1038,52 +1522,88 @@ mod tests { block_record.block_entity_handle = block_handle; block_record.block_end_handle = block_end_handle; host.document_mut().block_records.add(block_record).unwrap(); - let mut block = acadrust::entities::Block::new( - "TAGBLOCK", acadrust::types::Vector3::ZERO); + let mut block = acadrust::entities::Block::new("TAGBLOCK", acadrust::types::Vector3::ZERO); block.common.handle = block_handle; block.common.owner_handle = block_record_handle; - host.document_mut().add_entity(EntityType::Block(block)).unwrap(); + host.document_mut() + .add_entity(EntityType::Block(block)) + .unwrap(); let mut block_end = acadrust::entities::BlockEnd::new(); block_end.common.handle = block_end_handle; block_end.common.owner_handle = block_record_handle; - host.document_mut().add_entity(EntityType::BlockEnd(block_end)).unwrap(); + host.document_mut() + .add_entity(EntityType::BlockEnd(block_end)) + .unwrap(); let insert = acadrust::entities::Insert::new( - "TAGBLOCK", acadrust::types::Vector3::new(10.0, 0.0, 0.0)); - let insert_handle = host.document_mut().add_entity(EntityType::Insert(insert)).unwrap(); + "TAGBLOCK", + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ); + let insert_handle = host + .document_mut() + .add_entity(EntityType::Insert(insert)) + .unwrap(); let process = ocs_plugin_api::process::PluginProcess::spawn( - std::path::Path::new(&plugin_path), &mut host, + std::path::Path::new(&plugin_path), + &mut host, crate::plugin::v4_support::notification_handler(), - ).expect("spawn staged Python plugin"); + ) + .expect("spawn staged Python plugin"); let dispatch = |host: &mut HostSession<'_>, command: &str| { - assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); }; - dispatch(&mut host, &format!(concat!( + dispatch( + &mut host, + &format!( + concat!( "PY_EVAL ocs.active_document.create_entity('AttributeDefinition',", "owner_handle={},tag='PART_NO',prompt='Part number',default_value='PN-001',", "insertion_point={{'x':1.0,'y':2.0,'z':0.0}},height=2.5).handle" - ), block_record_handle.value())); - let handle = host.document().entities() + ), + block_record_handle.value() + ), + ); + let handle = host + .document() + .entities() .find(|entity| matches!(entity, EntityType::AttributeDefinition(_))) - .expect("Python created an AttributeDefinition").common().handle; - let Some(EntityType::AttributeDefinition(created)) = host.document().get_entity(handle) else { + .expect("Python created an AttributeDefinition") + .common() + .handle; + let Some(EntityType::AttributeDefinition(created)) = host.document().get_entity(handle) + else { panic!("expected AttributeDefinition"); }; assert_eq!(created.common.owner_handle, block_record_handle); assert_eq!(created.tag, "PART_NO"); assert_eq!(created.text_style, "Standard"); - assert!(host.document().block_records.get("TAGBLOCK").unwrap() - .entity_handles.contains(&handle)); + assert!(host + .document() + .block_records + .get("TAGBLOCK") + .unwrap() + .entity_handles + .contains(&handle)); let created = created.clone(); dispatch(&mut host, &format!( "PY_EVAL (ocs.active_document.entities[{}].tag, ocs.active_document.entities[{}].owner_handle)", handle.value(), handle.value())); let readback = &host.app.command_line.history.last().unwrap().text; - assert!(readback.contains("PART_NO") && readback.contains(&block_record_handle.value().to_string())); + assert!( + readback.contains("PART_NO") + && readback.contains(&block_record_handle.value().to_string()) + ); let script_path = std::env::temp_dir().join(format!( - "ocs_attribute_definition_document_model_{}.py", std::process::id())); - std::fs::write(&script_path, format!(concat!( + "ocs_attribute_definition_document_model_{}.py", + std::process::id() + )); + std::fs::write( + &script_path, + format!( + concat!( "doc = ocs.active_document\n", "definition = doc.entities[{}]\n", "with doc.transaction('Edit attribute definition'):\n", @@ -1092,12 +1612,21 @@ mod tests { " definition.rotation = 0.25\n", " definition.width_factor = 1.25\n", "doc.selection = [definition]\n" - ), handle.value())).unwrap(); + ), + handle.value() + ), + ) + .unwrap(); dispatch(&mut host, &format!("PY_RUN {}", script_path.display())); let _ = std::fs::remove_file(&script_path); let expected = host.document().get_entity(handle).unwrap().clone(); - let EntityType::AttributeDefinition(edited) = &expected else { unreachable!() }; - assert_eq!(edited.insertion_point, acadrust::types::Vector3::new(4.0, 5.0, 0.0)); + let EntityType::AttributeDefinition(edited) = &expected else { + unreachable!() + }; + assert_eq!( + edited.insertion_point, + acadrust::types::Vector3::new(4.0, 5.0, 0.0) + ); assert_eq!(edited.default_value, "PN-002"); assert_eq!(edited.rotation, 0.25); assert_eq!(edited.width_factor, 1.25); @@ -1108,15 +1637,22 @@ mod tests { assert_eq!(edited.flags, created.flags); assert_eq!(edited.embedded_mtext, created.embedded_mtext); assert_eq!(host.selection(), vec![handle]); - assert!(matches!(host.document().get_entity(insert_handle), Some(EntityType::Insert(value)) - if value.block_name == "TAGBLOCK")); + assert!( + matches!(host.document().get_entity(insert_handle), Some(EntityType::Insert(value)) + if value.block_name == "TAGBLOCK") + ); - let render = edited.to_render(host.document()).expect("attribute definition render"); - let crate::scene::convert::acad_to_render::RenderObject::Text(strokes) = render.object else { + let render = edited + .to_render(host.document()) + .expect("attribute definition render"); + let crate::scene::convert::acad_to_render::RenderObject::Text(strokes) = render.object + else { panic!("expected attribute definition text"); }; assert!(!strokes.is_empty()); - assert!(strokes.iter().any(|stroke| stroke.run.as_ref() + assert!(strokes.iter().any(|stroke| stroke + .run + .as_ref() .is_some_and(|run| run.text.contains("PN-002")))); for (label, patch, message) in [ @@ -1125,32 +1661,59 @@ mod tests { ("Reject style", "'text_style':'Missing'", "does not exist"), ("Reject owner", "'owner_handle':999999", "read-only"), ] { - dispatch(&mut host, &format!( - "PY_EVAL ocs.update_many({label:?}, [{{'handle':{}, {patch}}}])", handle.value())); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.update_many({label:?}, [{{'handle':{}, {patch}}}])", + handle.value() + ), + ); assert_eq!(host.document().get_entity(handle), Some(&expected)); - assert!(host.app.command_line.history.last().unwrap().text.contains(message)); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains(message)); } let assert_persisted = |label: &str, document: &CadDocument, full_text_state: bool| { let actual = document.get_entity(handle); - assert!(matches!(actual, Some(EntityType::AttributeDefinition(value)) + assert!( + matches!(actual, Some(EntityType::AttributeDefinition(value)) if value.common.owner_handle == block_record_handle && value.tag == "PART_NO" && value.default_value == "PN-002" && value.insertion_point == acadrust::types::Vector3::new(4.0, 5.0, 0.0) - && (value.rotation - 0.25).abs() < 1e-12), "{label}: {actual:?}"); + && (value.rotation - 0.25).abs() < 1e-12), + "{label}: {actual:?}" + ); if full_text_state { - assert!(matches!(actual, Some(EntityType::AttributeDefinition(value)) + assert!( + matches!(actual, Some(EntityType::AttributeDefinition(value)) if value.width_factor == 1.25 && value.text_style == "Standard" - && value.prompt == "Part number"), "{label}: {actual:?}"); + && value.prompt == "Part number"), + "{label}: {actual:?}" + ); } - assert!(document.block_records.get("TAGBLOCK").unwrap().entity_handles.contains(&handle)); - assert!(matches!(document.get_entity(insert_handle), Some(EntityType::Insert(value)) - if value.block_name == "TAGBLOCK")); + assert!(document + .block_records + .get("TAGBLOCK") + .unwrap() + .entity_handles + .contains(&handle)); + assert!( + matches!(document.get_entity(insert_handle), Some(EntityType::Insert(value)) + if value.block_name == "TAGBLOCK") + ); }; let dwg_bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); let dwg_doc = crate::io::load_bytes("attribute-definition.dwg", dwg_bytes).unwrap(); assert_persisted("DWG", &dwg_doc, true); - let dxf_bytes = acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap(); + let dxf_bytes = acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); let dxf_doc = crate::io::load_bytes("attribute-definition.dxf", dxf_bytes).unwrap(); // The pinned cadcodec DXF ATTDEF reader retains identity, placement, // height, value, prompt and rotation, but currently drops several @@ -1159,24 +1722,42 @@ mod tests { // complete DXF property round trip here. assert_persisted("DXF", &dxf_doc, false); - dispatch(&mut host, &format!( - "PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.delete_entity({})", + handle.value() + ), + ); assert!(host.document().get_entity(handle).is_none()); // CadDocument intentionally leaves the block membership slot in place // so delta undo can restore the shared entity without rewriting the // record. Writers must still omit the absent entity. let deleted_dwg = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); - assert!(crate::io::load_bytes("attribute-definition-deleted.dwg", deleted_dwg) - .unwrap().get_entity(handle).is_none()); - let deleted_dxf = acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap(); - assert!(crate::io::load_bytes("attribute-definition-deleted.dxf", deleted_dxf) - .unwrap().get_entity(handle).is_none()); + assert!( + crate::io::load_bytes("attribute-definition-deleted.dwg", deleted_dwg) + .unwrap() + .get_entity(handle) + .is_none() + ); + let deleted_dxf = acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(); + assert!( + crate::io::load_bytes("attribute-definition-deleted.dxf", deleted_dxf) + .unwrap() + .get_entity(handle) + .is_none() + ); drop(process); drop(host); app.finish_pending_history(0); assert_eq!(app.tabs[0].history.undo_stack.len(), 3); app.undo_steps(1); - assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + assert_eq!( + app.tabs[0].scene.document.get_entity(handle), + Some(&expected) + ); app.undo_steps(1); assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::AttributeDefinition(value)) @@ -1187,9 +1768,252 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } + #[test] + fn staged_python_attribute_entity_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let next = host.document().next_handle(); + let record_handle = Handle::new(next); + let block_handle = Handle::new(next + 1); + let end_handle = Handle::new(next + 2); + let mut record = acadrust::tables::BlockRecord::new("TAGBLOCK"); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = end_handle; + host.document_mut().block_records.add(record).unwrap(); + let mut block = acadrust::entities::Block::new("TAGBLOCK", acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + host.document_mut() + .add_entity(EntityType::Block(block)) + .unwrap(); + let mut end = acadrust::entities::BlockEnd::new(); + end.common.handle = end_handle; + end.common.owner_handle = record_handle; + host.document_mut() + .add_entity(EntityType::BlockEnd(end)) + .unwrap(); + let mut definition = acadrust::entities::AttributeDefinition::new( + "PART_NO".into(), + "Part number".into(), + "PN-001".into(), + ); + definition.common.owner_handle = record_handle; + definition.insertion_point = acadrust::types::Vector3::new(0.0, 2.0, 0.0); + let definition_handle = host + .document_mut() + .add_entity(EntityType::AttributeDefinition(definition)) + .unwrap(); + let insert_handle = host + .document_mut() + .add_entity(EntityType::Insert(acadrust::entities::Insert::new( + "TAGBLOCK", + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ))) + .unwrap(); + + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process + .dispatch(host, command, &mut |_| {}) + .expect("Python dispatch")); + }; + dispatch( + &mut host, + &format!( + concat!( + "PY_EVAL ocs.active_document.create_entity('AttributeEntity',owner_handle={},", + "tag='PART_NO',value='PN-101',", + "insertion_point={{'x':10.0,'y':2.0,'z':0.0}},height=2.5).handle" + ), + insert_handle.value() + ), + ); + let created_insert = match host.document().get_entity(insert_handle).unwrap() { + EntityType::Insert(insert) => insert, + _ => unreachable!(), + }; + assert_eq!(created_insert.attributes.len(), 1); + let attribute_handle = created_insert.attributes[0].common.handle; + assert_eq!( + created_insert.attributes[0].attdef_handle, + definition_handle + ); + let created = created_insert.attributes[0].clone(); + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.entities[{}].value", + attribute_handle.value() + ), + ); + let readback = &host.app.command_line.history.last().unwrap().text; + assert!(readback.contains("PN-101"), "{readback}"); + + let script = + std::env::temp_dir().join(format!("ocs_attribute_entity_{}.py", std::process::id())); + std::fs::write( + &script, + format!( + concat!( + "doc = ocs.active_document\n", + "attribute = doc.entities[{}]\n", + "with doc.transaction('Edit attribute'):\n", + " attribute.value = 'PN-102'\n", + " attribute.insertion_point = (12.0, 3.0, 0.0)\n", + " attribute.rotation = 0.25\n", + "doc.selection = [doc.entities[{}]]\n" + ), + attribute_handle.value(), + insert_handle.value() + ), + ) + .unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.nested_attribute(attribute_handle).unwrap(); + let EntityType::AttributeEntity(edited) = &expected else { + unreachable!() + }; + assert_eq!(edited.value, "PN-102"); + assert_eq!( + edited.insertion_point, + acadrust::types::Vector3::new(12.0, 3.0, 0.0) + ); + assert_eq!(edited.common, created.common); + assert_eq!(edited.attdef_handle, definition_handle); + assert_eq!(host.selection(), vec![insert_handle]); + let render = edited.to_render(host.document()).unwrap(); + assert!(matches!(render.object, + crate::scene::convert::acad_to_render::RenderObject::Text(ref strokes) + if !strokes.is_empty())); + + for (patch, message) in [ + ("'tag':'MISSING'", "no definition"), + ("'height':0.0", "greater than zero"), + ("'owner_handle':999999", "read-only"), + ] { + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.update_many('Reject attribute', [{{'handle':{}, {patch}}}])", + attribute_handle.value() + ), + ); + assert_eq!( + host.nested_attribute(attribute_handle), + Some(expected.clone()) + ); + assert!(host + .app + .command_line + .history + .last() + .unwrap() + .text + .contains(message)); + } + + let assert_saved = |document: &CadDocument| { + assert!( + matches!(document.get_entity(insert_handle), Some(EntityType::Insert(insert)) + if insert.attributes.len() == 1 + && insert.attributes[0].common.handle == attribute_handle + && insert.attributes[0].value == "PN-102" + && (insert.attributes[0].rotation - 0.25).abs() < 1e-12) + ); + }; + assert_saved( + &crate::io::load_bytes( + "attribute.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap(), + ) + .unwrap(), + ); + assert_saved( + &crate::io::load_bytes( + "attribute.dxf", + acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(), + ) + .unwrap(), + ); + + dispatch( + &mut host, + &format!( + "PY_EVAL ocs.active_document.delete_entity({})", + attribute_handle.value() + ), + ); + assert!(host.nested_attribute(attribute_handle).is_none()); + let assert_deleted = |document: &CadDocument| { + assert!( + matches!(document.get_entity(insert_handle), Some(EntityType::Insert(insert)) + if insert.attributes.is_empty()) + ); + }; + assert_deleted( + &crate::io::load_bytes( + "attribute-deleted.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap(), + ) + .unwrap(), + ); + assert_deleted( + &crate::io::load_bytes( + "attribute-deleted.dxf", + acadrust::DxfWriter::new(host.document()) + .write_to_vec() + .unwrap(), + ) + .unwrap(), + ); + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.len() == 1 + && insert.attributes[0].value == "PN-102") + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes[0].value == "PN-101") + ); + app.undo_steps(1); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.is_empty()) + ); + app.redo_steps(3); + assert!( + matches!(app.tabs[0].scene.document.get_entity(insert_handle), + Some(EntityType::Insert(insert)) if insert.attributes.is_empty()) + ); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { - let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); assert!(std::path::Path::new(&runner_path).is_file()); @@ -1197,38 +2021,70 @@ mod tests { app.tabs[0].is_start = false; let process = { let mut host = HostSession::new(&mut app, 0); - std::sync::Arc::new(ocs_plugin_api::process::PluginProcess::spawn( - std::path::Path::new(&plugin_path), &mut host, + std::sync::Arc::new( + ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, crate::plugin::v4_support::notification_handler(), - ).expect("spawn staged Python plugin")) + ) + .expect("spawn staged Python plugin"), + ) }; let request = |app: &mut OpenCADStudio, prompt: &str, entity: bool| { let mut started = None; { let mut host = HostSession::new(app, 0); - let method = if entity { "request_entity" } else { "request_point" }; + let method = if entity { + "request_entity" + } else { + "request_point" + }; let cmd = format!("PY_EVAL ocs.active_document.{method}('{prompt}')"); - assert!(process.dispatch(&mut host, &cmd, &mut |id| started = Some(id)).unwrap()); + assert!(process + .dispatch(&mut host, &cmd, &mut |id| started = Some(id)) + .unwrap()); } let output = app.command_line.history.last().unwrap().text.clone(); - let token = output.split_whitespace().find_map(|part| part.parse::().ok()) + let token = output + .split_whitespace() + .find_map(|part| part.parse::().ok()) .unwrap_or_else(|| panic!("Python pick token missing from {output:?}")); let id = started.expect("Python request started an interactive command"); - app.set_active_command(0, Box::new(PluginProcessInteractiveAdapter::new( - std::sync::Arc::clone(&process), id, - ))); + app.set_active_command( + 0, + Box::new(PluginProcessInteractiveAdapter::new( + std::sync::Arc::clone(&process), + id, + )), + ); token }; let picked_token = request(&mut app, "Pick a point", false); - let result = app.tabs[0].active_cmd.as_mut().unwrap().on_point(glam::DVec3::new(3.0, 4.0, 0.0)); + let result = app.tabs[0] + .active_cmd + .as_mut() + .unwrap() + .on_point(glam::DVec3::new(3.0, 4.0, 0.0)); let _ = app.apply_cmd_result(result); assert!(app.tabs[0].active_cmd.is_none()); { let mut host = HostSession::new(&mut app, 0); - assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({picked_token})"), &mut |_| {}).unwrap()); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({picked_token})"), + &mut |_| {} + ) + .unwrap()); } - assert!(app.command_line.history.last().unwrap().text.contains("point")); + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains("point")); let target = { let mut host = HostSession::new(&mut app, 0); @@ -1239,79 +2095,192 @@ mod tests { }; let entity_token = request(&mut app, "Pick an entity", true); assert!(app.tabs[0].active_cmd.as_ref().unwrap().needs_entity_pick()); - let result = app.tabs[0].active_cmd.as_mut().unwrap() + let result = app.tabs[0] + .active_cmd + .as_mut() + .unwrap() .on_entity_pick(target, glam::DVec3::new(6.0, 7.0, 0.0)); let _ = app.apply_cmd_result(result); { let mut host = HostSession::new(&mut app, 0); - assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({entity_token})"), &mut |_| {}).unwrap()); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({entity_token})"), + &mut |_| {} + ) + .unwrap()); } - assert!(app.command_line.history.last().unwrap().text.contains("entity")); - assert!(app.command_line.history.last().unwrap().text.contains(&target.value().to_string())); + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains("entity")); + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains(&target.value().to_string())); let cancelled_token = request(&mut app, "Cancel a point", false); let result = app.tabs[0].active_cmd.as_mut().unwrap().on_enter(); let _ = app.apply_cmd_result(result); { let mut host = HostSession::new(&mut app, 0); - assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({cancelled_token})"), &mut |_| {}).unwrap()); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({cancelled_token})"), + &mut |_| {} + ) + .unwrap()); } - assert!(app.command_line.history.last().unwrap().text.contains("cancelled")); + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains("cancelled")); } #[test] fn staged_python_tabs_isolate_tokens_and_notifications() { - let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); assert!(std::path::Path::new(&runner_path).is_file()); let mut app = OpenCADStudio::new_for_test(); app.tabs[0].is_start = false; - app.tabs.push(crate::app::document::DocumentTab::new_drawing(2)); + app.tabs + .push(crate::app::document::DocumentTab::new_drawing(2)); app.tabs[1].is_start = false; let tab0 = app.tabs[0].id; let tab1 = app.tabs[1].id; assert_ne!(tab0, tab1); let process = { let mut host = HostSession::new(&mut app, 0); - std::sync::Arc::new(ocs_plugin_api::process::PluginProcess::spawn( - std::path::Path::new(&plugin_path), &mut host, + std::sync::Arc::new( + ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, crate::plugin::v4_support::notification_handler(), - ).expect("spawn staged Python plugin")) + ) + .expect("spawn staged Python plugin"), + ) }; let mut started = None; { let mut host = HostSession::new(&mut app, 0); - assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.request_point('Tab zero')", - &mut |id| started = Some(id)).unwrap()); + assert!(process + .dispatch( + &mut host, + "PY_EVAL ocs.active_document.request_point('Tab zero')", + &mut |id| started = Some(id) + ) + .unwrap()); } - let token = app.command_line.history.last().unwrap().text.split_whitespace() - .find_map(|part| part.parse::().ok()).expect("pick token"); - app.set_active_command(0, Box::new(PluginProcessInteractiveAdapter::new( - std::sync::Arc::clone(&process), started.unwrap(), - ))); + let token = app + .command_line + .history + .last() + .unwrap() + .text + .split_whitespace() + .find_map(|part| part.parse::().ok()) + .expect("pick token"); + app.set_active_command( + 0, + Box::new(PluginProcessInteractiveAdapter::new( + std::sync::Arc::clone(&process), + started.unwrap(), + )), + ); { let mut host = HostSession::new(&mut app, 1); - assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({token})"), &mut |_| {}).unwrap()); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({token})"), + &mut |_| {} + ) + .unwrap()); } - assert!(app.command_line.history.last().unwrap().text.contains("None"), - "other tab must not consume the pending token"); - let result = app.tabs[0].active_cmd.as_mut().unwrap().on_point(glam::DVec3::new(2.0, 3.0, 0.0)); + assert!( + app.command_line + .history + .last() + .unwrap() + .text + .contains("None"), + "other tab must not consume the pending token" + ); + let result = app.tabs[0] + .active_cmd + .as_mut() + .unwrap() + .on_point(glam::DVec3::new(2.0, 3.0, 0.0)); let _ = app.apply_cmd_result(result); { let mut host = HostSession::new(&mut app, 1); - assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({token})"), &mut |_| {}).unwrap()); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({token})"), + &mut |_| {} + ) + .unwrap()); } - assert!(app.command_line.history.last().unwrap().text.contains("None")); + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains("None")); { let mut host = HostSession::new(&mut app, 0); - assert!(process.dispatch(&mut host, &format!("PY_EVAL ocs.active_document.poll_input({token})"), &mut |_| {}).unwrap()); + assert!(process + .dispatch( + &mut host, + &format!("PY_EVAL ocs.active_document.poll_input({token})"), + &mut |_| {} + ) + .unwrap()); } - assert!(app.command_line.history.last().unwrap().text.contains("point")); + assert!(app + .command_line + .history + .last() + .unwrap() + .text + .contains("point")); use ocs_plugin_api::host::HostNotification; - process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab0, epoch: 101 }).unwrap(); - process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab1, epoch: 202 }).unwrap(); + process + .notify_plugin( + None, + HostNotification::DrawingChanged { + tab_id: tab0, + epoch: 101, + }, + ) + .unwrap(); + process + .notify_plugin( + None, + HostNotification::DrawingChanged { + tab_id: tab1, + epoch: 202, + }, + ) + .unwrap(); // Notifications are best-effort and consumed by the runner's reader // thread before its next Dispatch. Give that thread a bounded handoff. std::thread::sleep(std::time::Duration::from_millis(50)); @@ -1320,40 +2289,88 @@ mod tests { for _ in 0..10 { { let mut host = HostSession::new(app, tab); - assert!(process.dispatch(&mut host, - "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); + assert!(process + .dispatch( + &mut host, + "PY_EVAL ocs.active_document.poll_events()", + &mut |_| {} + ) + .unwrap()); } output = app.command_line.history.last().unwrap().text.clone(); - if output.contains(epoch) { break; } + if output.contains(epoch) { + break; + } std::thread::sleep(std::time::Duration::from_millis(20)); } output }; let other_events = poll_events_until(&mut app, 1, "202"); - assert!(other_events.contains("202") && !other_events.contains("101"), "{other_events}"); + assert!( + other_events.contains("202") && !other_events.contains("101"), + "{other_events}" + ); let first_events = poll_events_until(&mut app, 0, "101"); - assert!(first_events.contains("101") && !first_events.contains("202"), "{first_events}"); + assert!( + first_events.contains("101") && !first_events.contains("202"), + "{first_events}" + ); for epoch in 0..257 { - process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab0, epoch }).unwrap(); + process + .notify_plugin( + None, + HostNotification::DrawingChanged { + tab_id: tab0, + epoch, + }, + ) + .unwrap(); } std::thread::sleep(std::time::Duration::from_millis(100)); { let mut host = HostSession::new(&mut app, 0); - assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); + assert!(process + .dispatch( + &mut host, + "PY_EVAL ocs.active_document.poll_events()", + &mut |_| {} + ) + .unwrap()); } let overflow = &app.command_line.history.last().unwrap().text; - assert!(overflow.contains("overflow") && overflow.contains("dropped"), "{overflow}"); + assert!( + overflow.contains("overflow") && overflow.contains("dropped"), + "{overflow}" + ); - process.notify_plugin(None, HostNotification::DrawingChanged { tab_id: tab0, epoch: 303 }).unwrap(); - process.notify_plugin(None, HostNotification::DocumentTabClosed { tab_id: tab0 }).unwrap(); + process + .notify_plugin( + None, + HostNotification::DrawingChanged { + tab_id: tab0, + epoch: 303, + }, + ) + .unwrap(); + process + .notify_plugin(None, HostNotification::DocumentTabClosed { tab_id: tab0 }) + .unwrap(); std::thread::sleep(std::time::Duration::from_millis(50)); { let mut host = HostSession::new(&mut app, 0); - assert!(process.dispatch(&mut host, "PY_EVAL ocs.active_document.poll_events()", &mut |_| {}).unwrap()); + assert!(process + .dispatch( + &mut host, + "PY_EVAL ocs.active_document.poll_events()", + &mut |_| {} + ) + .unwrap()); } - assert!(app.command_line.history.last().unwrap().text.contains("[]"), - "closing a tab must discard its queued events"); + assert!( + app.command_line.history.last().unwrap().text.contains("[]"), + "closing a tab must discard its queued events" + ); } #[test] @@ -1365,14 +2382,28 @@ mod tests { let mut host = HostSession::new(&mut app, 0); handle = host.add_entity(EntityType::Hatch(acadrust::entities::Hatch::default())); let original = host.document().get_entity(handle).unwrap(); - let changed = ocs_plugin_api::entity_coverage::patch_canvas_layer(original, "HATCHES").unwrap(); - host.update_entities_transaction("Move hatch layer", vec![changed]).unwrap(); - assert_eq!(host.document().get_entity(handle).unwrap().common().layer, "HATCHES"); + let changed = + ocs_plugin_api::entity_coverage::patch_canvas_layer(original, "HATCHES").unwrap(); + host.update_entities_transaction("Move hatch layer", vec![changed]) + .unwrap(); + assert_eq!( + host.document().get_entity(handle).unwrap().common().layer, + "HATCHES" + ); } app.finish_pending_history(0); assert_eq!(app.tabs[0].history.undo_stack.len(), 1); app.undo_steps(1); - assert_eq!(app.tabs[0].scene.document.get_entity(handle).unwrap().common().layer, "0"); + assert_eq!( + app.tabs[0] + .scene + .document + .get_entity(handle) + .unwrap() + .common() + .layer, + "0" + ); } #[test] @@ -1382,22 +2413,30 @@ mod tests { let handle; { let mut host = HostSession::new(&mut app, 0); - let insert = acadrust::entities::Insert::new("DOOR", acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + let insert = acadrust::entities::Insert::new( + "DOOR", + acadrust::types::Vector3::new(1.0, 2.0, 0.0), + ); handle = host.add_entity(EntityType::Insert(insert)); let mut changed = host.document().get_entity(handle).unwrap().clone(); if let EntityType::Insert(insert) = &mut changed { insert.insert_point.x = 4.0; insert.set_x_scale(2.0); } - host.update_entities_transaction("Move block", vec![changed]).unwrap(); - assert!(matches!(host.document().get_entity(handle), Some(EntityType::Insert(insert)) - if insert.insert_point.x == 4.0 && insert.x_scale() == 2.0 && insert.block_name == "DOOR")); + host.update_entities_transaction("Move block", vec![changed]) + .unwrap(); + assert!( + matches!(host.document().get_entity(handle), Some(EntityType::Insert(insert)) + if insert.insert_point.x == 4.0 && insert.x_scale() == 2.0 && insert.block_name == "DOOR") + ); } app.finish_pending_history(0); assert_eq!(app.tabs[0].history.undo_stack.len(), 1); app.undo_steps(1); - assert!(matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Insert(insert)) - if insert.insert_point.x == 1.0 && insert.x_scale() == 1.0 && insert.block_name == "DOOR")); + assert!( + matches!(app.tabs[0].scene.document.get_entity(handle), Some(EntityType::Insert(insert)) + if insert.insert_point.x == 1.0 && insert.x_scale() == 1.0 && insert.block_name == "DOOR") + ); } #[test] @@ -1405,28 +2444,56 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); app.tabs[0].is_start = false; let mut host = HostSession::new(&mut app, 0); - assert_eq!(host.system_variable("clayer"), Some(HostSettingValue::Text("0".into()))); - assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(0.0))); - assert!(host.set_system_variable("CLAYER", HostSettingValue::Text("Missing".into())).is_err()); - assert_eq!(host.system_variable("CLAYER"), Some(HostSettingValue::Text("0".into()))); + assert_eq!( + host.system_variable("clayer"), + Some(HostSettingValue::Text("0".into())) + ); + assert_eq!( + host.system_variable("SNAPANG"), + Some(HostSettingValue::Number(0.0)) + ); + assert!(host + .set_system_variable("CLAYER", HostSettingValue::Text("Missing".into())) + .is_err()); + assert_eq!( + host.system_variable("CLAYER"), + Some(HostSettingValue::Text("0".into())) + ); let mut point = Point::new(); point.common.layer = "Annotations".into(); host.add_entity(EntityType::Point(point)); - assert_eq!(host.set_system_variable("clayer", HostSettingValue::Text("Annotations".into())), - Ok(HostSettingValue::Text("Annotations".into()))); - assert_eq!(host.system_variable("CLAYER"), Some(HostSettingValue::Text("Annotations".into()))); + assert_eq!( + host.set_system_variable("clayer", HostSettingValue::Text("Annotations".into())), + Ok(HostSettingValue::Text("Annotations".into())) + ); + assert_eq!( + host.system_variable("CLAYER"), + Some(HostSettingValue::Text("Annotations".into())) + ); assert_eq!(host.app.tabs[0].active_layer, "Annotations"); assert_eq!(host.app.tabs[0].layers.current_layer, "Annotations"); assert_eq!(host.app.ribbon.active_layer, "Annotations"); assert!(host.app.tabs[0].dirty); - assert_eq!(host.set_system_variable("snapang", HostSettingValue::Number(450.0)), - Ok(HostSettingValue::Number(90.0))); - assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(90.0))); - assert!(host.set_system_variable("SNAPANG", HostSettingValue::Number(f64::INFINITY)).is_err()); - assert!(host.set_system_variable("SNAPANG", HostSettingValue::Number(f64::MAX)).is_err()); - assert_eq!(host.system_variable("SNAPANG"), Some(HostSettingValue::Number(90.0))); + assert_eq!( + host.set_system_variable("snapang", HostSettingValue::Number(450.0)), + Ok(HostSettingValue::Number(90.0)) + ); + assert_eq!( + host.system_variable("SNAPANG"), + Some(HostSettingValue::Number(90.0)) + ); + assert!(host + .set_system_variable("SNAPANG", HostSettingValue::Number(f64::INFINITY)) + .is_err()); + assert!(host + .set_system_variable("SNAPANG", HostSettingValue::Number(f64::MAX)) + .is_err()); + assert_eq!( + host.system_variable("SNAPANG"), + Some(HostSettingValue::Number(90.0)) + ); } #[test] @@ -1524,7 +2591,10 @@ mod tests { assert!(host.update_entity(edited)); // Same handle, edit applied, geometry re-tessellated. - let got = host.document().get_entity(h).expect("entity kept its handle"); + let got = host + .document() + .get_entity(h) + .expect("entity kept its handle"); assert_eq!(got.common().layer, "PLUGIN_EDIT"); assert_ne!( host.app.tabs[0].scene.geometry_epoch, epoch_before, @@ -1628,12 +2698,18 @@ mod tests { host.start_interactive(Box::new(PlacePoint { got_first: false })); } assert!(app.tabs[0].active_cmd.is_some()); - for pt in [glam::DVec3::new(0.0, 0.0, 0.0), glam::DVec3::new(5.0, 5.0, 0.0)] { + for pt in [ + glam::DVec3::new(0.0, 0.0, 0.0), + glam::DVec3::new(5.0, 5.0, 0.0), + ] { let r = app.tabs[0].active_cmd.as_mut().unwrap().on_point(pt); let _ = app.apply_cmd_result(r); } assert_eq!(app.tabs[0].scene.document.entities().count(), 1); - assert!(app.tabs[0].active_cmd.is_none(), "command should have ended"); + assert!( + app.tabs[0].active_cmd.is_none(), + "command should have ended" + ); } /// A plugin command that picks an existing object, then marks it. @@ -1705,8 +2781,9 @@ mod tests { app.tabs[0].is_start = false; let mut host = HostSession::new(&mut app, 0); let info = host.document_view().unwrap(); - let reader = - ocs_plugin_api::shm::SharedDocumentReader::::open(std::path::Path::new(&info.path)) + let reader = ocs_plugin_api::shm::SharedDocumentReader::< + ocs_plugin_api::shm::DocumentViewData, + >::open(std::path::Path::new(&info.path)) .unwrap(); assert_eq!(reader.entity_count(), 0); @@ -1756,8 +2833,9 @@ mod tests { app.tabs[0].is_start = false; let mut host = HostSession::new(&mut app, 0); let info = host.document_view().unwrap(); - let reader = - ocs_plugin_api::shm::SharedDocumentReader::::open(std::path::Path::new(&info.path)) + let reader = ocs_plugin_api::shm::SharedDocumentReader::< + ocs_plugin_api::shm::DocumentViewData, + >::open(std::path::Path::new(&info.path)) .unwrap(); let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new( From e0d35ac84c5db00aa6006d7396f1e48032401a05 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 13:17:17 +0200 Subject: [PATCH 12/76] Record AttributeEntity completion --- docs/plugin-host-model-completion-plan.md | 8 +++++--- docs/plugin-host-model-coverage-ledger.md | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 5c54e0c85..94acc1822 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -4,7 +4,7 @@ Build a general Python document model over the OCS host API for all **43 canvas kinds**. A kind is **complete** only after the per-kind gate below passes. Merely appearing in a converter or allowing a `layer` edit is partial coverage. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). Do not resume the separate Lisp-to-Python conversion work. -As of 19 September 2026, the current AttributeDefinition increment is OCS commit `5b854bd1` on `plugin/host-model-api` and PandoraBox commit `bb259ff` on `main`. Host API is v7. The Python feature has explicit writes for **20 of 43** canvas kinds: 19 are creatable and `Insert` remains update-only. This is **not** 20 completed kinds: only Tolerance and Shape pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the pinned acadrust DXF reader drops optional ATTDEF fields. The other 23 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. +As of 19 September 2026, the current AttributeEntity increment is OCS commit `cf2a9235` on `plugin/host-model-api` and PandoraBox commit `9c6b5c3` on `main`. Host API is v7. The Python feature has explicit writes for **21 of 43** canvas kinds: 20 are creatable and `Insert` remains update-only. This is **not** 21 completed kinds: Tolerance, Shape and AttributeEntity pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the pinned acadrust DXF reader drops optional ATTDEF fields. The other 22 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. Repositories: @@ -48,7 +48,7 @@ If OCS/acadrust cannot create or serialize a kind reliably, mark it **blocked**, Audit these against the same gate: `Point`, `Line`, `Circle`, `Arc`, `Ellipse`, `Polyline`, `Polyline2D`, `Polyline3D`, `LwPolyline`, `Spline`, `Text`, `MText`, `Ray`, `XLine`, `Solid`, `Face3D`, `Insert`. `Insert` currently has transform edits but **no Python creation**; its block name is read-only and attached attributes are snapshot-only. Earlier tests include some direct DWG round trips, but there is no evidence that every mapped kind passes real IPC, GUI, deletion, undo/redo, and both file formats. Mark each independently after testing. -## Order for the 26 canvas kinds without mapped geometry writes +## Ordered 26-kind expansion queue The order below groups shared implementation work. Complete a kind only through the gate above. Within a group, move past a blocked kind after recording the exact dependency. “Minimum exercise” identifies a real entity-specific create/edit/delete case; it does not make other fields automatically writable. @@ -93,7 +93,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **AttributeEntity**, using the existing block fixture and new creation-time `owner_handle` support. Its owner must be a real linked `Insert`, and the test must preserve sequence/attribute references through edit, deletion, undo/redo and both file formats. AttributeDefinition is mapped and integration-tested but remains open at W: pinned acadrust `568a12c` loses optional ATTDEF fields such as width factor on DXF read. Do not mark it complete until that dependency is fixed and the full property state passes DXF save/reopen. Keep the exact remaining-kind table above as the queue and update statuses with evidence after each increment. +Continue with **Hatch**. Build structurally valid solid and patterned hatch fixtures with closed boundaries, expose only fields whose mutations OCS can validate, and verify fill rendering, boundary integrity, deletion, undo/redo and both file formats through real IPC. AttributeDefinition remains mapped and integration-tested but open at W: pinned acadrust `568a12c` loses optional ATTDEF fields such as width factor on DXF read. Do not mark it complete until that dependency is fixed and the full property state passes DXF save/reopen. Keep the exact queue above and update statuses with evidence after each increment. ## Phase 8 progress log @@ -113,3 +113,5 @@ Continue with **AttributeEntity**, using the existing block fixture and new crea - Mapped stable AttributeDefinition fields and validated tag, placement, normal, height, width, angles, text style, line count and block ownership. `embedded_mtext` stays snapshot-readable but unmapped so partial Python edits preserve its linked R2018+ layout payload. - `staged_python_attribute_definition_lifecycle_over_real_ipc` passes the real create/read/edit/delete/undo/selection/render/validation/portable-build path using a structurally valid block and linked Insert. Full DWG state and core DXF geometry survive reopen; deletion stays absent after both formats. OCS also stopped double-converting ATTDEF/ATTRIB DXF rotation already converted by acadrust. - AttributeDefinition remains **Integration-tested / DXF blocked**, not Complete: acadrust `568a12c`'s ATTDEF DXF reader does not restore optional AcDbText fields including width factor. The real test and ledger record that exact W-gate gap. The queue now continues with `AttributeEntity`. +- Closed `AttributeEntity` without flattening canonical drawing storage. V4 views and IPC snapshots expose each nested child by handle, while host create/update/delete and transactions rewrite the owning Insert so rendering, serialization and undo remain consistent. +- `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the actual v7 adapter. It creates a linked attribute from its block's ATTDEF, reads and edits value/placement/rotation, selects the parent Insert, renders text, rejects invalid tag/height/owner edits atomically, reopens edited and deleted state in DWG and DXF, and proves three-step undo/redo. AttributeEntity is **Complete**; the queue now continues with `Hatch`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index b2d779706..7448f488a 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **20 of 43 canvas kinds have explicit geometry writes** (19 creatable, plus update-only `Insert`), **2 kinds are complete**, and 23 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **21 of 43 canvas kinds have explicit geometry writes** (20 creatable, plus update-only `Insert`), **3 kinds are complete**, and 22 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = portable H7 v5 build. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -26,7 +26,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | | Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | | AttributeDefinition | **Integration-tested / DXF blocked** | Host commit `5b854bd1` and adapter commit `bb259ff` map block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, render text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open** because pinned acadrust `568a12c` drops optional ATTDEF fields including width factor while reading DXF; the regression test keeps this limitation explicit. `embedded_mtext` mutation also remains unsupported pending linked-layout validation. | -| AttributeEntity | Layer-only | Requires linked Insert and attribute-sequence fixture. | +| AttributeEntity | **Complete** | OCS commit `cf2a9235` and adapter commit `9c6b5c3` make nested ATTRIB records addressable by handle while retaining canonical storage in `Insert.attributes`. Host validation binds tag to a definition in the Insert's block, validates placement/text geometry and style references, keeps owner and definition handles read-only, and preserves embedded MTEXT. `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a real block, ATTDEF and Insert: high-level create/read/transaction edit/delete, parent selection, rendered text, atomic invalid tag/height/owner rejection, edited and deleted DWG/DXF reopen, and three-step undo/redo. `nested_insert_attribute_crud_uses_parent_storage_and_undo` additionally proves the child never becomes an orphan flat entity in the host document. | | Hatch | Layer-only | Requires valid boundary and pattern fixtures. | | Leader | Layer-only | Requires annotation-linked fixture. | | MLine | Layer-only | Requires valid style and vertex fixture. | From 98b56ef673a8377faead7508a55898209d1a4103 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 18:44:31 +0200 Subject: [PATCH 13/76] Complete Hatch host model coverage --- crates/ocs_plugin_api/build.rs | 16 + .../entity_coverage_policy.json | 3 +- crates/ocs_plugin_api/src/entity_coverage.rs | 316 ++++++++++++++++++ src/app/plugin_host.rs | 145 +++++++- 4 files changed, 478 insertions(+), 2 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 0f7e09cd0..dd58dd74c 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -740,6 +740,22 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | "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" + ) ) { "transaction_geometry" } else { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 341449ebc..931ae0806 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -22,7 +22,8 @@ "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"] + "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"] }, "readable": { "Insert": ["block_name"], diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index cbfa829d0..da8c33bd3 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -61,6 +61,176 @@ fn insert_scale(name: &str, value: f64) -> Result<(), String> { } } +#[cfg(feature = "host")] +fn validate_hatch_paths(paths: &[crate::host::acadrust::entities::BoundaryPath]) -> Result<(), String> { + use crate::host::acadrust::entities::BoundaryEdge; + use crate::host::acadrust::types::Vector2; + let close = |a: Vector2, b: Vector2| (a.x - b.x).hypot(a.y - b.y) <= 1e-8; + let ellipse_point = |center: Vector2, major: Vector2, ratio: f64, angle: f64| { + Vector2::new( + center.x + major.x * angle.cos() - major.y * ratio * angle.sin(), + center.y + major.y * angle.cos() + major.x * ratio * angle.sin(), + ) + }; + if paths.is_empty() { + return Err("Hatch requires at least one boundary path".into()); + } + for (pi, path) in paths.iter().enumerate() { + if path.edges.is_empty() { + return Err(format!("Hatch boundary path {pi} has no edges")); + } + if path.flags.is_not_closed() { + return Err(format!("Hatch boundary path {pi} is marked open")); + } + if path.flags.bits() & !0x1ff != 0 { + return Err(format!("Hatch boundary path {pi} has unknown flag bits")); + } + let mut endpoints = Vec::with_capacity(path.edges.len()); + for (ei, edge) in path.edges.iter().enumerate() { + let pair = match edge { + BoundaryEdge::Line(line) => { + if !line.start.x.is_finite() || !line.start.y.is_finite() || !line.end.x.is_finite() || !line.end.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} line has non-finite coordinates")); + } + if (line.start.x - line.end.x).hypot(line.start.y - line.end.y) < 1e-12 { + return Err(format!("Hatch boundary path {pi} edge {ei} line has zero length")); + } + (line.start, line.end) + } + BoundaryEdge::CircularArc(arc) => { + if !arc.center.x.is_finite() || !arc.center.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} arc center has non-finite coordinates")); + } + if !arc.radius.is_finite() || arc.radius <= 0.0 { + return Err(format!("Hatch boundary path {pi} edge {ei} arc radius must be finite and positive")); + } + if !arc.start_angle.is_finite() || !arc.end_angle.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} arc angles must be finite")); + } + let point = |angle: f64| Vector2::new( + arc.center.x + arc.radius * angle.cos(), + arc.center.y + arc.radius * angle.sin(), + ); + let (start, end) = (point(arc.start_angle), point(arc.end_angle)); + if arc.counter_clockwise { (start, end) } else { (end, start) } + } + BoundaryEdge::EllipticArc(arc) => { + if !arc.center.x.is_finite() || !arc.center.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse center has non-finite coordinates")); + } + if !arc.major_axis_endpoint.x.is_finite() || !arc.major_axis_endpoint.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse major axis has non-finite coordinates")); + } + if arc.major_axis_endpoint.x.hypot(arc.major_axis_endpoint.y) < 1e-12 { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse major axis has zero length")); + } + if !arc.minor_axis_ratio.is_finite() || !(0.0..=1.0).contains(&arc.minor_axis_ratio) || arc.minor_axis_ratio == 0.0 { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse ratio must be within (0, 1]")); + } + if !arc.start_angle.is_finite() || !arc.end_angle.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} ellipse angles must be finite")); + } + let start = ellipse_point(arc.center, arc.major_axis_endpoint, arc.minor_axis_ratio, arc.start_angle); + let end = ellipse_point(arc.center, arc.major_axis_endpoint, arc.minor_axis_ratio, arc.end_angle); + if arc.counter_clockwise { (start, end) } else { (end, start) } + } + BoundaryEdge::Polyline(poly) => { + if poly.vertices.len() < 3 { + return Err(format!("Hatch boundary path {pi} edge {ei} polyline must have at least 3 vertices")); + } + for (vi, v) in poly.vertices.iter().enumerate() { + if !v.x.is_finite() || !v.y.is_finite() || !v.z.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} vertex {vi} has non-finite coordinates")); + } + } + if !poly.is_closed { + return Err(format!("Hatch boundary path {pi} edge {ei} polyline is open")); + } + let first = poly.vertices.first().unwrap(); + (Vector2::new(first.x, first.y), Vector2::new(first.x, first.y)) + } + BoundaryEdge::Spline(spline) => { + if spline.degree < 1 { + return Err(format!("Hatch boundary path {pi} edge {ei} spline degree must be at least 1")); + } + if spline.control_points.len() < spline.degree as usize + 1 { + return Err(format!("Hatch boundary path {pi} edge {ei} spline has too few control points for its degree")); + } + if spline.knots.iter().any(|knot| !knot.is_finite()) + || spline.knots.windows(2).any(|pair| pair[0] > pair[1]) + { + return Err(format!("Hatch boundary path {pi} edge {ei} spline knots must be finite and nondecreasing")); + } + for (ci, cp) in spline.control_points.iter().enumerate() { + if !cp.x.is_finite() || !cp.y.is_finite() || !cp.z.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} control point {ci} has non-finite coordinates")); + } + } + for (fi, fp) in spline.fit_points.iter().enumerate() { + if !fp.x.is_finite() || !fp.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} fit point {fi} has non-finite coordinates")); + } + } + for (name, tangent) in [("start", spline.start_tangent), ("end", spline.end_tangent)] { + if !tangent.x.is_finite() || !tangent.y.is_finite() { + return Err(format!("Hatch boundary path {pi} edge {ei} spline {name} tangent is non-finite")); + } + } + let points = if spline.fit_points.len() >= 2 { + spline.fit_points.clone() + } else { + spline.control_points.iter().map(|p| Vector2::new(p.x, p.y)).collect() + }; + if points.len() < 2 { + return Err(format!("Hatch boundary path {pi} edge {ei} spline has too few points")); + } + (*points.first().unwrap(), *points.last().unwrap()) + } + }; + endpoints.push(pair); + } + for ei in 0..endpoints.len() { + let next = (ei + 1) % endpoints.len(); + if !close(endpoints[ei].1, endpoints[next].0) { + return Err(format!("Hatch boundary path {pi} is not closed between edges {ei} and {next}")); + } + } + } + Ok(()) +} + +#[cfg(feature = "host")] +fn validate_hatch_pattern(value: &crate::host::acadrust::entities::Hatch) -> Result<(), String> { + if value.pattern.name.trim().is_empty() { + return Err("Hatch.pattern.name is empty".into()); + } + if value.is_solid { + if !value.pattern.name.eq_ignore_ascii_case("SOLID") || !value.pattern.lines.is_empty() { + return Err("solid Hatch requires the SOLID pattern without pattern lines".into()); + } + return Ok(()); + } + if !value.pattern_scale.is_finite() || value.pattern_scale <= 0.0 { + return Err("Hatch.pattern_scale must be finite and greater than zero".into()); + } + if value.pattern.lines.is_empty() { + return Err("patterned Hatch requires at least one pattern line".into()); + } + for (index, line) in value.pattern.lines.iter().enumerate() { + if !line.angle.is_finite() + || !line.base_point.x.is_finite() || !line.base_point.y.is_finite() + || !line.offset.x.is_finite() || !line.offset.y.is_finite() + || line.dash_lengths.iter().any(|dash| !dash.is_finite()) + { + return Err(format!("Hatch.pattern.lines[{index}] contains non-finite values")); + } + if line.offset.x.hypot(line.offset.y) < 1e-12 { + return Err(format!("Hatch.pattern.lines[{index}].offset must be nonzero")); + } + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -232,6 +402,25 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() return Err("AttributeEntity.line_count must be greater than zero".into()); } } + EntityType::Hatch(value) => { + solid_normal("Hatch.normal", &value.normal)?; + if !value.elevation.is_finite() { + return Err("Hatch.elevation must be finite".into()); + } + validate_hatch_pattern(value)?; + if !value.pattern_angle.is_finite() { + return Err("Hatch.pattern_angle must be finite".into()); + } + if !value.pixel_size.is_finite() || value.pixel_size < 0.0 { + return Err("Hatch.pixel_size must be finite and non-negative".into()); + } + for (si, sp) in value.seed_points.iter().enumerate() { + if !sp.x.is_finite() || !sp.y.is_finite() { + return Err(format!("Hatch.seed_points[{si}] has non-finite coordinates")); + } + } + validate_hatch_paths(&value.paths)?; + } _ => {} } Ok(()) @@ -610,6 +799,50 @@ pub fn validate_entity_mutation( return Err("AttributeEntity.attdef_handle is read-only".into()); } } + (EntityType::Hatch(old), EntityType::Hatch(new)) => { + if !same3(&old.normal, &new.normal) { + solid_normal("Hatch.normal", &new.normal)?; + } + if old.elevation.to_bits() != new.elevation.to_bits() && !new.elevation.is_finite() { + return Err("Hatch.elevation must be finite".into()); + } + if old.pattern != new.pattern + || old.is_solid != new.is_solid + || old.pattern_scale.to_bits() != new.pattern_scale.to_bits() + { + validate_hatch_pattern(new)?; + } + if old.pattern_angle.to_bits() != new.pattern_angle.to_bits() + && !new.pattern_angle.is_finite() + { + return Err("Hatch.pattern_angle must be finite".into()); + } + if old.pixel_size.to_bits() != new.pixel_size.to_bits() + && (!new.pixel_size.is_finite() || new.pixel_size < 0.0) + { + return Err("Hatch.pixel_size must be finite and non-negative".into()); + } + if old.paths != new.paths { + validate_hatch_paths(&new.paths)?; + } + if old.seed_points != new.seed_points { + for (si, sp) in new.seed_points.iter().enumerate() { + if !sp.x.is_finite() || !sp.y.is_finite() { + return Err(format!("Hatch.seed_points[{si}] has non-finite coordinates")); + } + } + } + if old.gradient_color != new.gradient_color { + return Err("Hatch.gradient_color is unmapped and read-only".into()); + } + if old.is_mpolygon != new.is_mpolygon + || old.mpolygon_hatch_color != new.mpolygon_hatch_color + || old.mpolygon_x_direction != new.mpolygon_x_direction + || old.mpolygon_boundary_handle_count != new.mpolygon_boundary_handle_count + { + return Err("Hatch MPOLYGON fields are unmapped and read-only".into()); + } + } _ => {} } Ok(()) @@ -635,6 +868,22 @@ pub fn validate_canvas_entity_references( return Err(format!("entity owner {owner:?} does not exist")); } } + if let EntityType::Hatch(value) = entity { + let handles = value.paths.iter().flat_map(|path| &path.boundary_handles); + let mut count = 0usize; + for handle in handles { + count += 1; + if handle.is_null() || document.get_entity(*handle).is_none() { + return Err(format!("Hatch boundary handle {handle:?} does not exist")); + } + } + if value.is_associative && count == 0 { + return Err("associative Hatch requires boundary handles".into()); + } + if !value.is_associative && count != 0 { + return Err("non-associative Hatch cannot carry boundary handles".into()); + } + } if let EntityType::Tolerance(value) = entity { let found = value .dimension_style_handle @@ -952,6 +1201,7 @@ mod tests { | "Shape" | "AttributeDefinition" | "AttributeEntity" + | "Hatch" ) { assert!( @@ -1073,6 +1323,19 @@ mod tests { "AttributeEntity.is_multiline".to_owned(), "AttributeEntity.line_count".to_owned(), "AttributeEntity.lock_position".to_owned(), + "Hatch.elevation".to_owned(), + "Hatch.normal".to_owned(), + "Hatch.is_solid".to_owned(), + "Hatch.pattern".to_owned(), + "Hatch.pattern_angle".to_owned(), + "Hatch.pattern_scale".to_owned(), + "Hatch.pattern_type".to_owned(), + "Hatch.is_double".to_owned(), + "Hatch.style".to_owned(), + "Hatch.is_associative".to_owned(), + "Hatch.pixel_size".to_owned(), + "Hatch.paths".to_owned(), + "Hatch.seed_points".to_owned(), ]) ); } @@ -1415,4 +1678,57 @@ mod tests { .contains("no whitespace") ); } + #[test] + fn hatch_geometry_and_boundaries_are_validated() { + use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, LineEdge}; + let mut hatch = acadrust::entities::Hatch::new(); + let mut path = BoundaryPath::new(); + for (start, end) in [ + ((0.0, 0.0), (10.0, 0.0)), + ((10.0, 0.0), (10.0, 10.0)), + ((10.0, 10.0), (0.0, 10.0)), + ((0.0, 10.0), (0.0, 0.0)), + ] { + path.edges.push(BoundaryEdge::Line(LineEdge { + start: acadrust::types::Vector2::new(start.0, start.1), + end: acadrust::types::Vector2::new(end.0, end.1), + })); + } + hatch.paths.push(path); + let entity = acadrust::EntityType::Hatch(hatch.clone()); + validate_new_canvas_entity(&entity).unwrap(); + + let mut invalid = hatch.clone(); + invalid.paths.clear(); + assert!(validate_new_canvas_entity(&acadrust::EntityType::Hatch(invalid)) + .unwrap_err() + .contains("at least one boundary path")); + + let mut invalid = hatch.clone(); + invalid.is_solid = false; + invalid.pattern_scale = 0.0; + assert!(validate_new_canvas_entity(&acadrust::EntityType::Hatch(invalid)) + .unwrap_err() + .contains("greater than zero")); + + let mut document = acadrust::CadDocument::new(); + let boundary = document.add_entity(acadrust::EntityType::Line( + acadrust::entities::Line::from_points( + acadrust::types::Vector3::ZERO, + acadrust::types::Vector3::UNIT_X, + ), + )).unwrap(); + let mut associative = hatch.clone(); + associative.is_associative = true; + associative.paths[0].boundary_handles.push(boundary); + validate_canvas_entity_references( + &document, + &acadrust::EntityType::Hatch(associative.clone()), + ).unwrap(); + associative.paths[0].boundary_handles[0] = acadrust::Handle::new(u64::MAX); + assert!(validate_canvas_entity_references( + &document, + &acadrust::EntityType::Hatch(associative), + ).unwrap_err().contains("does not exist")); + } } diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index d60531410..e1a152d9f 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -2009,6 +2009,149 @@ mod tests { ); } + #[test] + fn staged_python_hatch_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let script = std::env::temp_dir().join(format!("ocs_hatch_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "def p(x, y): return {'x': x, 'y': y}\n", + "def boundary(size):\n", + " pairs = [(0.0,0.0,size,0.0),(size,0.0,size,size),(size,size,0.0,size),(0.0,size,0.0,0.0)]\n", + " edges = [{'kind':'Line','value':{'start':p(a,b),'end':p(c,d)}} for a,b,c,d in pairs]\n", + " return {'flags':1,'edges':edges,'boundary_handles':[]}\n", + "doc = ocs.active_document\n", + "hatch = doc.create_entity('Hatch', paths=[boundary(10.0)])\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let hatch_handle = host.document().entities().find_map(|entity| match entity { + EntityType::Hatch(hatch) => Some(hatch.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create Hatch: {:?}", + host.app.command_line.history.last().map(|entry| &entry.text))); + let created = host.document().get_entity(hatch_handle).unwrap().clone(); + assert!(host.app.tabs[0].scene.hatches.get(&hatch_handle) + .is_some_and(|model| model.boundary.len() >= 4 && model.name == "SOLID")); + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.entities[{}].pattern['name']", hatch_handle.value())); + assert!(host.app.command_line.history.last().unwrap().text.contains("SOLID")); + + let script = std::env::temp_dir().join(format!("ocs_hatch_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def p(x, y): return {{'x': x, 'y': y}}\n", + "def boundary(size):\n", + " pairs = [(0.0,0.0,size,0.0),(size,0.0,size,size),(size,size,0.0,size),(0.0,size,0.0,0.0)]\n", + " edges = [{{'kind':'Line','value':{{'start':p(a,b),'end':p(c,d)}}}} for a,b,c,d in pairs]\n", + " return {{'flags':1,'edges':edges,'boundary_handles':[]}}\n", + "doc = ocs.active_document\n", + "hatch = doc.entities[{}]\n", + "with doc.transaction('Edit hatch'):\n", + " hatch.paths = [boundary(12.0)]\n", + " hatch.is_solid = False\n", + " hatch.pattern = {{'name':'TEST','description':'test pattern','lines':[", + "{{'angle':0.0,'base_point':p(0.0,0.0),'offset':p(0.0,2.0),'dash_lengths':[1.0,-1.0]}}]}}\n", + " hatch.pattern_scale = 2.0\n", + " hatch.pattern_angle = 0.25\n", + "doc.selection = [hatch]\n" + ), hatch_handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(hatch_handle).unwrap().clone(); + let EntityType::Hatch(edited) = &expected else { unreachable!() }; + assert!(!edited.is_solid); + assert_eq!(edited.pattern.name, "TEST"); + assert_eq!(edited.pattern.lines.len(), 1); + assert_eq!(edited.paths[0].edges.len(), 4); + assert!((edited.pattern_scale - 2.0).abs() < 1e-12); + assert_eq!(edited.gradient_color, match &created { EntityType::Hatch(value) => value.gradient_color.clone(), _ => unreachable!() }); + assert_eq!(host.selection(), vec![hatch_handle]); + assert!(host.app.tabs[0].scene.hatches.get(&hatch_handle).is_some_and(|model| + model.boundary.len() >= 4 && model.name == "TEST" + && matches!(&model.pattern, + crate::scene::model::hatch_model::HatchPattern::Pattern(lines) + if !lines.is_empty()))); + + for (patch, message) in [ + ("'pattern_scale':0.0", "greater than zero"), + ("'is_associative':True", "requires boundary handles"), + ("'gradient_color':{}", "outside the editable schema"), + ("'paths':[{'flags':1,'edges':[{'kind':'Line','value':{'start':{'x':0.0,'y':0.0},'end':{'x':1.0,'y':0.0}}}],'boundary_handles':[]}]", "not closed"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject hatch', [{{'handle':{}, {patch}}}])", + hatch_handle.value())); + assert_eq!(host.document().get_entity(hatch_handle), Some(&expected)); + assert!(host.app.command_line.history.last().unwrap().text.contains(message)); + } + + let assert_saved = |document: &CadDocument| { + assert!(matches!(document.get_entity(hatch_handle), Some(EntityType::Hatch(hatch)) + if !hatch.is_solid && hatch.pattern.name == "TEST" + && hatch.pattern.lines.len() == 1 && hatch.paths[0].edges.len() == 4 + && (hatch.pattern_scale - 2.0).abs() < 1e-12)); + }; + let reopened_dwg = crate::io::load_bytes("hatch.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let reopened_dxf = crate::io::load_bytes("hatch.dxf", + acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + assert_saved(&reopened_dwg); + assert_saved(&reopened_dxf); + for (name, mut document) in [("hatch-reedit.dwg", reopened_dwg), + ("hatch-reedit.dxf", reopened_dxf)] { + let before = document.get_entity(hatch_handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::Hatch(hatch) = &mut after else { unreachable!() }; + hatch.elevation = 3.0; + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + *document.get_entity_mut(hatch_handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(hatch_handle), + Some(EntityType::Hatch(hatch)) if (hatch.elevation - 3.0).abs() < 1e-12)); + } + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.delete_entity({})", hatch_handle.value())); + assert!(host.document().get_entity(hatch_handle).is_none()); + for (name, bytes) in [ + ("hatch-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("hatch-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(hatch_handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(hatch_handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(hatch_handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(hatch_handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(hatch_handle).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { @@ -2380,7 +2523,7 @@ mod tests { let handle; { let mut host = HostSession::new(&mut app, 0); - handle = host.add_entity(EntityType::Hatch(acadrust::entities::Hatch::default())); + handle = host.add_entity(EntityType::MLine(acadrust::entities::MLine::default())); let original = host.document().get_entity(handle).unwrap(); let changed = ocs_plugin_api::entity_coverage::patch_canvas_layer(original, "HATCHES").unwrap(); From 889042c1f1064518a54d8bcc825f9ce966d4f7ba Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 18:45:13 +0200 Subject: [PATCH 14/76] Record Hatch completion --- docs/plugin-host-model-completion-plan.md | 6 ++++-- docs/plugin-host-model-coverage-ledger.md | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 94acc1822..77a807f69 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -4,7 +4,7 @@ Build a general Python document model over the OCS host API for all **43 canvas kinds**. A kind is **complete** only after the per-kind gate below passes. Merely appearing in a converter or allowing a `layer` edit is partial coverage. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). Do not resume the separate Lisp-to-Python conversion work. -As of 19 September 2026, the current AttributeEntity increment is OCS commit `cf2a9235` on `plugin/host-model-api` and PandoraBox commit `9c6b5c3` on `main`. Host API is v7. The Python feature has explicit writes for **21 of 43** canvas kinds: 20 are creatable and `Insert` remains update-only. This is **not** 21 completed kinds: Tolerance, Shape and AttributeEntity pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the pinned acadrust DXF reader drops optional ATTDEF fields. The other 22 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. +As of 19 September 2026, the current Hatch increment is OCS commit `98b56ef6` on `plugin/host-model-api` and PandoraBox commit `a9b5de8` on `main`. Host API is v7. The Python feature has explicit writes for **22 of 43** canvas kinds: 21 are creatable and `Insert` remains update-only. This is **not** 22 completed kinds: Tolerance, Shape, AttributeEntity and Hatch pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the pinned acadrust DXF reader drops optional ATTDEF fields. The other 21 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. Repositories: @@ -93,7 +93,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **Hatch**. Build structurally valid solid and patterned hatch fixtures with closed boundaries, expose only fields whose mutations OCS can validate, and verify fill rendering, boundary integrity, deletion, undo/redo and both file formats through real IPC. AttributeDefinition remains mapped and integration-tested but open at W: pinned acadrust `568a12c` loses optional ATTDEF fields such as width factor on DXF read. Do not mark it complete until that dependency is fixed and the full property state passes DXF save/reopen. Keep the exact queue above and update statuses with evidence after each increment. +Continue with **Leader**. Build a valid vertex path and annotation-linked fixture, map geometry separately from annotation/object handles, and verify path rendering, selection, reference preservation, deletion, undo/redo and both file formats through real IPC. AttributeDefinition remains mapped and integration-tested but open at W: pinned acadrust `568a12c` loses optional ATTDEF fields such as width factor on DXF read. Do not mark it complete until that dependency is fixed and the full property state passes DXF save/reopen. Keep the exact queue above and update statuses with evidence after each increment. ## Phase 8 progress log @@ -115,3 +115,5 @@ Continue with **Hatch**. Build structurally valid solid and patterned hatch fixt - AttributeDefinition remains **Integration-tested / DXF blocked**, not Complete: acadrust `568a12c`'s ATTDEF DXF reader does not restore optional AcDbText fields including width factor. The real test and ledger record that exact W-gate gap. The queue now continues with `AttributeEntity`. - Closed `AttributeEntity` without flattening canonical drawing storage. V4 views and IPC snapshots expose each nested child by handle, while host create/update/delete and transactions rewrite the owning Insert so rendering, serialization and undo remain consistent. - `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the actual v7 adapter. It creates a linked attribute from its block's ATTDEF, reads and edits value/placement/rotation, selects the parent Insert, renders text, rejects invalid tag/height/owner edits atomically, reopens edited and deleted state in DWG and DXF, and proves three-step undo/redo. AttributeEntity is **Complete**; the queue now continues with `Hatch`. +- Completed `Hatch` with full solid/pattern definitions and typed nested boundary paths. The adapter generator now handles single-payload tagged enums as `{kind, value}` dictionaries, which covers every Hatch edge variant and is reusable by later nested entity models. Unknown adapter properties are rejected instead of being silently ignored. +- `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with closed line-loop fixtures: solid creation, conversion to a stored-line pattern, boundary replacement, selection and live render-cache checks, atomic invalid scale/association/open-loop/unmapped-payload rejection, edited/deleted DWG and DXF reopen, post-reopen edits, and three-step undo/redo. Hatch is **Complete**; the queue now continues with `Leader`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 7448f488a..fdd3e3913 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **21 of 43 canvas kinds have explicit geometry writes** (20 creatable, plus update-only `Insert`), **3 kinds are complete**, and 22 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **22 of 43 canvas kinds have explicit geometry writes** (21 creatable, plus update-only `Insert`), **4 kinds are complete**, and 21 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = portable H7 v5 build. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -27,7 +27,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | | AttributeDefinition | **Integration-tested / DXF blocked** | Host commit `5b854bd1` and adapter commit `bb259ff` map block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, render text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open** because pinned acadrust `568a12c` drops optional ATTDEF fields including width factor while reading DXF; the regression test keeps this limitation explicit. `embedded_mtext` mutation also remains unsupported pending linked-layout validation. | | AttributeEntity | **Complete** | OCS commit `cf2a9235` and adapter commit `9c6b5c3` make nested ATTRIB records addressable by handle while retaining canonical storage in `Insert.attributes`. Host validation binds tag to a definition in the Insert's block, validates placement/text geometry and style references, keeps owner and definition handles read-only, and preserves embedded MTEXT. `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a real block, ATTDEF and Insert: high-level create/read/transaction edit/delete, parent selection, rendered text, atomic invalid tag/height/owner rejection, edited and deleted DWG/DXF reopen, and three-step undo/redo. `nested_insert_attribute_crud_uses_parent_storage_and_undo` additionally proves the child never becomes an orphan flat entity in the host document. | -| Hatch | Layer-only | Requires valid boundary and pattern fixtures. | +| Hatch | **Complete** | OCS commit `98b56ef6` and adapter commit `a9b5de8` expose solid and patterned Hatches with elevation/normal, complete pattern definitions, pattern controls, style, association state, boundary paths and seed points. Nested tagged boundary edges use the reusable `{kind, value}` adapter form. Host validation requires closed finite loops, validates line/arc/ellipse/polyline/spline payloads and stored pattern lines, resolves associative boundary handles, and preserves gradient and MPOLYGON payloads as explicitly unsupported. `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: Python solid creation, patterned boundary edit, live render-model pattern/boundary checks, selection, atomic invalid scale/association/open-loop/unmapped-property rejection, edited and deleted DWG/DXF reopen, post-reopen edits, and three-step undo/redo. | | Leader | Layer-only | Requires annotation-linked fixture. | | MLine | Layer-only | Requires valid style and vertex fixture. | | Dimension | Layer-only | Track individual dimension subtypes. | From de51c1a17f81ef9885cb334f962808bdb7e1eae2 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sat, 19 Sep 2026 23:16:27 +0200 Subject: [PATCH 15/76] Bundle the first-party Python scripting plugin --- .github/workflows/release.yml | 5 + docs/plugin-architecture.md | 12 +- packaging/build_macos_signed.sh | 7 + plugins/opencad-python/Cargo.lock | 3059 +++++++++++ plugins/opencad-python/Cargo.toml | 47 + plugins/opencad-python/PLUGIN.md | 386 ++ plugins/opencad-python/README.md | 120 + plugins/opencad-python/ROADMAP.md | 393 ++ plugins/opencad-python/build.rs | 28 + plugins/opencad-python/build/generate.rs | 891 +++ plugins/opencad-python/entity_manifest.json | 233 + .../examples/create_layout_fixture.rs | 54 + plugins/opencad-python/examples/example.py | 90 + plugins/opencad-python/plugin.toml | 22 + plugins/opencad-python/src/document_model.py | 133 + plugins/opencad-python/src/event_cache.rs | 223 + plugins/opencad-python/src/host_ctx.rs | 81 + plugins/opencad-python/src/input_cache.rs | 159 + plugins/opencad-python/src/lib.rs | 321 ++ plugins/opencad-python/src/ocs_module.rs | 4865 +++++++++++++++++ plugins/opencad-python/src/selection_cache.rs | 33 + .../tests/test_document_model.py | 124 + plugins/opencad-python/tools/stage-bundled.sh | 75 + src/plugin/external.rs | 99 +- src/plugin/mod.rs | 8 +- src/ui/window/plugin_manager.rs | 18 +- 26 files changed, 11459 insertions(+), 27 deletions(-) create mode 100644 plugins/opencad-python/Cargo.lock create mode 100644 plugins/opencad-python/Cargo.toml create mode 100644 plugins/opencad-python/PLUGIN.md create mode 100644 plugins/opencad-python/README.md create mode 100644 plugins/opencad-python/ROADMAP.md create mode 100644 plugins/opencad-python/build.rs create mode 100644 plugins/opencad-python/build/generate.rs create mode 100644 plugins/opencad-python/entity_manifest.json create mode 100644 plugins/opencad-python/examples/create_layout_fixture.rs create mode 100644 plugins/opencad-python/examples/example.py create mode 100644 plugins/opencad-python/plugin.toml create mode 100644 plugins/opencad-python/src/document_model.py create mode 100644 plugins/opencad-python/src/event_cache.rs create mode 100644 plugins/opencad-python/src/host_ctx.rs create mode 100644 plugins/opencad-python/src/input_cache.rs create mode 100644 plugins/opencad-python/src/lib.rs create mode 100644 plugins/opencad-python/src/ocs_module.rs create mode 100644 plugins/opencad-python/src/selection_cache.rs create mode 100644 plugins/opencad-python/tests/test_document_model.py create mode 100755 plugins/opencad-python/tools/stage-bundled.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e659b1b8..fba2c1098 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -416,6 +416,11 @@ jobs: chmod +x "$APP/Contents/MacOS/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App" cp AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" cp DWG.icns DXF.icns "$APP/Contents/Resources/" + # First-party Python scripting plugin. It remains out of process and + # uses the same API/toolchain as this host build. + bash plugins/opencad-python/tools/stage-bundled.sh \ + "$APP/Contents/Resources/plugins/opencad.python" \ + --target aarch64-apple-darwin # QuickLook thumbnail extension. cp -R DWGThumbnail.appex "$APP/Contents/PlugIns/" # Substitute version into Info.plist. diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index 19b1a0968..b83e315a6 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -9,12 +9,12 @@ with Open CAD Studio. The model follows [QGIS](https://plugins.qgis.org/)-style extensibility: a small metadata file, a single entry point, an optional separate engine crate, and user-installable packages from a curated index. -> **Open CAD Studio ships no built-in plugins.** Every add-on is an **external -> dynamic library** (`cdylib`) the host loads at runtime from the user plugins -> folder. The host source only contains the generic plugin *runtime* +> Plugins are **out-of-process dynamic libraries** (`cdylib`). The host loads +> first-party packages from its application resources and third-party packages +> from the user plugins folder. Both use the generic runtime > (`src/plugin/`, `src/app/plugin_host.rs`) and the stable contract crate -> (`crates/ocs_plugin_api`). Add-ons live in their own repositories and -> **consume** that contract. +> (`crates/ocs_plugin_api`). A user package with the same id explicitly +> overrides a bundled package. --- @@ -24,7 +24,7 @@ engine crate, and user-installable packages from a curated index. |------|-----------| | **One package, one entry point** | Manifest, ribbon tab and commands ship together in the plugin crate; no edits to the host. | | **Stable contract** | Authors target the semver-versioned `ocs_plugin_api` crate, not `OpenCADStudio` internals. | -| **Out-of-tree by default** | A plugin is its own repo + crate; the host never recompiles to gain one. | +| **Out-of-tree by default** | Third-party plugins remain independent; selected first-party plugins may build and ship with OCS. | | **DWG round-trip** | Domain data lives on entities as XDATA, not in an opaque side database. | | **Engine reuse** | A headless `std`-only engine crate can run in WASM/CLI without the CAD host. | diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index aadce077c..b5905a902 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -89,6 +89,8 @@ cp "target/$TARGET/release/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App chmod +x "$APP/Contents/MacOS/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App" cp "$DIST/AppIcon.icns" "$DIST/DWG.icns" "$DIST/DXF.icns" "$APP/Contents/Resources/" cp -R "$EXT" "$APP/Contents/PlugIns/" +bash plugins/opencad-python/tools/stage-bundled.sh \ + "$APP/Contents/Resources/plugins/opencad.python" --target "$TARGET" sed "s/__VERSION__/$VERSION/g" packaging/Info.plist > "$APP/Contents/Info.plist" echo "==> codesign" @@ -104,6 +106,8 @@ if [ "$DEVELOPER_ID" = "-" ]; then # not the blocker here — carrying the entitlements through is. codesign --force --sign - --timestamp=none \ "$APP/Contents/MacOS/OpenCADStudio-App" + codesign --force --sign - --timestamp=none \ + "$APP/Contents/Resources/plugins/opencad.python/libopencad_python.dylib" codesign --force --sign - --timestamp=none \ --entitlements crates/dwg-thumbnailer/macos/entitlements.plist \ "$APP/Contents/PlugIns/DWGThumbnail.appex" @@ -114,6 +118,9 @@ else # and a secure timestamp on every layer for notarization. codesign --force --timestamp --options runtime \ -s "$DEVELOPER_ID" "$APP/Contents/MacOS/OpenCADStudio-App" + codesign --force --timestamp --options runtime \ + -s "$DEVELOPER_ID" \ + "$APP/Contents/Resources/plugins/opencad.python/libopencad_python.dylib" codesign --force --timestamp --options runtime \ --entitlements crates/dwg-thumbnailer/macos/entitlements.plist \ -s "$DEVELOPER_ID" "$APP/Contents/PlugIns/DWGThumbnail.appex" diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock new file mode 100644 index 000000000..1679360ae --- /dev/null +++ b/plugins/opencad-python/Cargo.lock @@ -0,0 +1,3059 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "acadrust" +version = "0.5.5" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=568a12c#568a12cb97c24e14b1cc488085b3f81cf89b94d9" +dependencies = [ + "ahash 0.8.12", + "anyhow", + "bitflags", + "byteorder", + "encoding_rs", + "flate2", + "indexmap", + "itoa", + "memmap2", + "nom", + "once_cell", + "rayon", + "ryu", + "serde", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "attribute-derive" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77" +dependencies = [ + "attribute-derive-macro", + "derive-where", + "manyhow", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "attribute-derive-macro" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61" +dependencies = [ + "collection_literals", + "interpolator", + "manyhow", + "proc-macro-utils", + "proc-macro2", + "quote", + "quote-use", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitflagset" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b6ee310aa7af14142c8c9121775774ff601ae055ed98ba7fac96098bcde1b9" +dependencies = [ + "num-integer", + "num-traits", + "radium 1.1.1", + "ref-cast", +] + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium 0.7.0", + "tap", + "wyz", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cargo-lock" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50524592e6bfbb1bf6f94e8184a786f637faeaf1cf37bbe65502b9a2c5c48939" +dependencies = [ + "semver", + "serde", + "toml", + "url", +] + +[[package]] +name = "caseless" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" +dependencies = [ + "unicode-normalization", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "collection_literals" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "derive-where" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "doctest-file" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encoding_rs" +version = "0.8.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-discriminant" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1a6df962265a53221f29081896c412ef325c17fa7d638cd9578febe53d3c82c" +dependencies = [ + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + +[[package]] +name = "exitcode" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de853764b47027c2e862a995c34978ffa63c1501f2e15f987ba11bd4f9bba193" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "get-size-derive2" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b6d1e2f75c16bfbcd0f95d84f99858a6e2f885c2287d1f5c3a96e8444a34b4" +dependencies = [ + "attribute-derive", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "get-size2" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49cf31a6d70300cf81461098f7797571362387ef4bf85d32ac47eaa59b3a5a1a" +dependencies = [ + "compact_str", + "get-size-derive2", + "hashbrown 0.16.1", + "ordermap", + "smallvec", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "interpolator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8" + +[[package]] +name = "interprocess" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb02ffbf25e8ef0f4a95bff054eef75f6d1bf16919474cf2caa63b21997478a" +dependencies = [ + "doctest-file", + "libc", + "recvmsg", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "is-macro" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8267aa6001e25494f3015f9663bbd88a18240c74483afa5f0934a1b3e4c388e9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "junction" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libffi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a8d526bc07aa02e6826944ec446ecd8d228bd97b1cbd21e5bc5e3407a09a585" +dependencies = [ + "libc", + "libffi-sys", +] + +[[package]] +name = "libffi-sys" +version = "4.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab801a4ae9065bfea87f37204a7940523e752056e4a090eaaaab9b47fc826b5" +dependencies = [ + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + +[[package]] +name = "malachite-base" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f44099731f17094b07825c88ccb5fbd1bfa1f82fafff7daa33e8b8652db16e" +dependencies = [ + "hashbrown 0.16.1", + "itertools", + "libm", + "ryu", +] + +[[package]] +name = "malachite-bigint" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc58206ba15e9c406e20c95c5f86efa07b12f94080945908e910b3a0faa23fef" +dependencies = [ + "malachite-base", + "malachite-nz", + "num-integer", + "num-traits", + "paste", +] + +[[package]] +name = "malachite-nz" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a137660cdba20f136c8a223125f08088adb4e0b72fbb8466f08c43e31cc0427d" +dependencies = [ + "itertools", + "libm", + "malachite-base", + "wide", +] + +[[package]] +name = "malachite-q" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ffcbeed95e34c0fcc3864ccd146e129cbbf7de1513d3afbcfb47c7674c82d94" +dependencies = [ + "itertools", + "libm", + "malachite-base", + "malachite-nz", +] + +[[package]] +name = "manyhow" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" +dependencies = [ + "manyhow-macros", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "manyhow-macros" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "multiversion" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c" +dependencies = [ + "multiversion-macros", +] + +[[package]] +name = "multiversion-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 3.0.6", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "ocs_plugin_api" +version = "0.2.1" +dependencies = [ + "acadrust", + "bincode", + "cargo-lock", + "getrandom 0.2.17", + "interprocess", + "libloading 0.8.9", + "memmap2", + "rkyv", + "serde", + "serde-reflection", + "serde_bytes", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opencad-python" +version = "0.1.0" +dependencies = [ + "ocs_plugin_api", + "rustpython-vm", + "serde", + "serde_json", +] + +[[package]] +name = "optional" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978aa494585d3ca4ad74929863093e87cac9790d81fe7aba2b3dc2890643a0fc" + +[[package]] +name = "ordermap" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a756ddcdeb6c0efc8927bdf0ed112885815a35ba350472a467be5104f01ca1" +dependencies = [ + "indexmap", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pmutil" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a40bc70c2c58040d2d8b167ba9a5ff59fc9dab7ad44771cfde3dcfde7a09c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-utils" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" +dependencies = [ + "proc-macro2", + "quote", + "smallvec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quote-use" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e" +dependencies = [ + "quote", + "quote-use-macros", +] + +[[package]] +name = "quote-use-macros" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35" +dependencies = [ + "proc-macro-utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "radium" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1775bc532a9bfde46e26eba441ca1171b91608d14a3bae71fea371f18a00cffe" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "result-like" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bffa194499266bd8a1ac7da6ac7355aa0f81ffa1a5db2baaf20dd13854fd6f4e" +dependencies = [ + "result-like-derive", +] + +[[package]] +name = "result-like-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d3b03471c9700a3a6bd166550daaa6124cb4a146ea139fb028e4edaa8f4277" +dependencies = [ + "pmutil", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustpython-codegen" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12e4fc1331357a7afc6d52d637796a946b1efaaca07c914132f8fd4fd69d595f" +dependencies = [ + "ahash 0.8.12", + "bitflags", + "indexmap", + "itertools", + "log", + "malachite-bigint", + "memchr", + "num-complex", + "num-traits", + "rustpython-compiler-core", + "rustpython-literal", + "rustpython-ruff_python_ast", + "rustpython-ruff_text_size", + "rustpython-wtf8", + "thiserror 2.0.20", + "unicode_names2 2.0.0", +] + +[[package]] +name = "rustpython-common" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f3c0808a170ea900e0489a06d6aa04ce5d27d62b347836c2f98df958f47f243" +dependencies = [ + "ascii", + "bitflags", + "cfg-if", + "getrandom 0.3.4", + "itertools", + "libc", + "lock_api", + "malachite-base", + "malachite-bigint", + "malachite-q", + "nix", + "num-complex", + "num-traits", + "radium 1.1.1", + "rustpython-literal", + "rustpython-wtf8", + "siphasher", + "unicode_names2 2.0.0", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustpython-compiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7fa83b852bbc5461c5214060099d7597785d201aa0bf1a87d77967518f60144" +dependencies = [ + "rustpython-codegen", + "rustpython-compiler-core", + "rustpython-ruff_python_ast", + "rustpython-ruff_python_parser", + "rustpython-ruff_source_file", + "rustpython-ruff_text_size", + "thiserror 2.0.20", +] + +[[package]] +name = "rustpython-compiler-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f18ba67b55aec49c3e2d223bbdcacff0d7e0f5d304fb0e64b044b8dca13c7c" +dependencies = [ + "bitflags", + "bitflagset", + "itertools", + "lz4_flex", + "malachite-bigint", + "num-complex", + "rustpython-ruff_source_file", + "rustpython-wtf8", +] + +[[package]] +name = "rustpython-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6c04350bee9e54b241b93675a0558c2b33fe982213e47a6da0924898af0f2" +dependencies = [ + "rustpython-compiler", + "rustpython-derive-impl", + "syn 2.0.119", +] + +[[package]] +name = "rustpython-derive-impl" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322d64ea8a21d52cd769db0f6190b6e7c17963b13c8c17f39d7364e68af96731" +dependencies = [ + "itertools", + "maplit", + "proc-macro2", + "quote", + "rustpython-compiler-core", + "rustpython-doc", + "syn 2.0.119", + "syn-ext", + "textwrap", +] + +[[package]] +name = "rustpython-doc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f286d8b5872aa53dceb7a8d8c6a29fe85150f0a137fecf1c9a3e0a8345cbc19b" +dependencies = [ + "phf 0.13.1", +] + +[[package]] +name = "rustpython-literal" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcb46f7afce00e4797f82062e09881ec5e7226d858104bf4a8a9c3b996de2b4a" +dependencies = [ + "hexf-parse", + "is-macro", + "lexical-parse-float", + "num-traits", + "rustpython-wtf8", + "unic-ucd-category", +] + +[[package]] +name = "rustpython-ruff_python_ast" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f021ff72cabf5e2cd6d8ec8813d376a8445a228dc610ab56c27bd9054cda70d4" +dependencies = [ + "aho-corasick", + "bitflags", + "compact_str", + "get-size2", + "is-macro", + "memchr", + "rustc-hash", + "rustpython-ruff_python_trivia", + "rustpython-ruff_source_file", + "rustpython-ruff_text_size", + "thiserror 2.0.20", +] + +[[package]] +name = "rustpython-ruff_python_parser" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e6ee78bd9671fb5766664b2695fe1f2a92a961f4d9101646c570d8acdb1e0b" +dependencies = [ + "bitflags", + "bstr", + "compact_str", + "get-size2", + "memchr", + "rustc-hash", + "rustpython-ruff_python_ast", + "rustpython-ruff_python_trivia", + "rustpython-ruff_text_size", + "static_assertions", + "unicode-ident", + "unicode-normalization", + "unicode_names2 1.3.0", +] + +[[package]] +name = "rustpython-ruff_python_trivia" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79e7cfd1056f3a02ff0d2d0e4474286ca963260782f878b7b81c1dd87432e682" +dependencies = [ + "itertools", + "rustpython-ruff_source_file", + "rustpython-ruff_text_size", + "unicode-ident", +] + +[[package]] +name = "rustpython-ruff_source_file" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948107aad62ddb12a11fc7bf68a49e52a0b0a3737d415a2505e54f5a9edac737" +dependencies = [ + "memchr", + "rustpython-ruff_text_size", +] + +[[package]] +name = "rustpython-ruff_text_size" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8291ee0f5a779e54ccd4e0151a0c426f8b49a123f99b5b6545db17ccdd4277aa" +dependencies = [ + "get-size2", +] + +[[package]] +name = "rustpython-sre_engine" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad4b7bd82b2a531f7c2ad96864ce11285959fc7d8524f1ba54db8ce3a23a3d2c" +dependencies = [ + "bitflags", + "num_enum", + "optional", + "rustpython-wtf8", +] + +[[package]] +name = "rustpython-vm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1880770161cef896bf9c7e065dae574e3b4c3cf6172e485f1ce86f0483816ab2" +dependencies = [ + "ahash 0.8.12", + "ascii", + "bitflags", + "bstr", + "caseless", + "cfg-if", + "chrono", + "constant_time_eq", + "crossbeam-utils", + "errno", + "exitcode", + "getrandom 0.3.4", + "glob", + "half", + "hex", + "indexmap", + "is-macro", + "itertools", + "junction", + "libc", + "libffi", + "libloading 0.9.0", + "log", + "malachite-bigint", + "memchr", + "nix", + "num-complex", + "num-integer", + "num-traits", + "num_cpus", + "num_enum", + "optional", + "parking_lot", + "paste", + "psm", + "result-like", + "rustix", + "rustpython-codegen", + "rustpython-common", + "rustpython-compiler", + "rustpython-compiler-core", + "rustpython-derive", + "rustpython-literal", + "rustpython-ruff_python_ast", + "rustpython-ruff_python_parser", + "rustpython-ruff_text_size", + "rustpython-sre_engine", + "rustyline", + "scoped-tls", + "scopeguard", + "static_assertions", + "strum", + "strum_macros", + "thiserror 2.0.20", + "timsort", + "uname", + "unic-ucd-bidi", + "unic-ucd-category", + "unic-ucd-ident", + "unicode-casing", + "which", + "widestring", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustpython-wtf8" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada88d2f69ff5516d69e0f3294e9db2ff8ee71a15291b8f3f8584f07ad1ca28d" +dependencies = [ + "ascii", + "bstr", + "itertools", + "memchr", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustyline" +version = "17.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.60.2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-reflection" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af0fdf9c543af37502581e3d6cf4e6cd1628d6616d6a1f8f4ca88aae3a405f5c" +dependencies = [ + "erased-discriminant", + "once_cell", + "serde", + "serde_json", + "thiserror 1.0.69", + "typeid", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-ext" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b126de4ef6c2a628a68609dd00733766c3b015894698a438ebdf374933fc31d1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "textwrap" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "timsort" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639ce8ef6d2ba56be0383a94dd13b92138d58de44c62618303bb798fa92bdc00" + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "twox-hash" +version = "2.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-bidi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1d568b51222484e1f8209ce48caa6b430bf352962b877d592c29ab31fb53d8c" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-category" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8d4591f5fcfe1bd4453baaf803c40e1b1e69ff8455c47620440b46efef91c0" +dependencies = [ + "matches", + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-casing" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061dbb8cc7f108532b6087a0065eff575e892a4bcb503dc57323a197457cc202" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode_names2" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +dependencies = [ + "phf 0.11.3", + "unicode_names2_generator 1.3.0", +] + +[[package]] +name = "unicode_names2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d189085656ca1203291e965444e7f6a2723fbdd1dd9f34f8482e79bafd8338a0" +dependencies = [ + "phf 0.11.3", + "unicode_names2_generator 2.0.0", +] + +[[package]] +name = "unicode_names2_generator" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" +dependencies = [ + "getopts", + "log", + "phf_codegen", + "rand", +] + +[[package]] +name = "unicode_names2_generator" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1262662dc96937c71115228ce2e1d30f41db71a7a45d3459e98783ef94052214" +dependencies = [ + "phf_codegen", + "rand", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "wide" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d920ac99c3c8edce110cb8d07dbb324d6d026011dce85b1e9355b70f0adacc4f" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/opencad-python/Cargo.toml b/plugins/opencad-python/Cargo.toml new file mode 100644 index 000000000..600cd2d5b --- /dev/null +++ b/plugins/opencad-python/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "opencad-python" +version = "0.1.0" +edition = "2021" +description = "Python scripting for Open CAD Studio (embeds RustPython)." +license = "GPL-3.0-only" + +[workspace] +resolver = "2" + +# A plugin is a C-ABI dynamic library the host loads at runtime. +[lib] +crate-type = ["cdylib"] + +[features] +default = [] +# Requires an external HostApi patch providing run_command/set_selection. +# Never enable against the released OCS host: nested command replay can hang. +experimental-command-replay = [] +# Requires the general HostApi system-variable methods in this OCS fork. +experimental-host-settings = [] +# Requires the in-tree OCS host API v7. +experimental-host-model = ["dep:serde_json"] + +[dependencies] +# The stable contract, with the runtime `host` surface (HostApi / BuiltinPlugin). +ocs_plugin_api = { path = "../../crates/ocs_plugin_api", features = ["host"] } +# Embedded interpreter for Phase 1 (see this crate's PLUGIN.md and ROADMAP.md). +# default-features = false, no "host_env": that default feature bakes `posix` +# (full filesystem access, confirmed: open('/etc/passwd').read() works with +# it on) and `_ctypes` (raw native-code-calling primitives) into every +# interpreter unconditionally, regardless of never adding rustpython-stdlib — +# see this crate's PLUGIN.md's sandboxing section for the audit that found +# this. "compiler" is required (Vm::compile); "gc" matches the default set +# for normal memory behavior. "wasmbind"/"stdio" are irrelevant off-web. +rustpython-vm = { version = "0.5", default-features = false, features = ["compiler", "gc"] } +serde_json = { version = "1", optional = true } + +[build-dependencies] +# No "host" feature: `type_registry`/`type_registry_types` (the only modules +# build.rs touches, via `get_embedded_type_registry_json`) are unconditionally +# compiled and depend on nothing but `serde` — the "host" feature only adds +# the acadrust-typed runtime surface build.rs has no use for. See this crate's +# own `src/lib.rs` doc comment on the module split. +ocs_plugin_api = { path = "../../crates/ocs_plugin_api" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md new file mode 100644 index 000000000..f885cdf0e --- /dev/null +++ b/plugins/opencad-python/PLUGIN.md @@ -0,0 +1,386 @@ +# Python Scripting (`opencad.python`) + +## Sandboxing (Phase 1.5) + +**Audited, not assumed — and the assumption was wrong.** Earlier phases +claimed "no stdlib is loaded, so `import os`/`socket`/`subprocess` is +already impossible." True for those three names, but incomplete: RustPython +0.5.0's `host_env` Cargo feature — **on by default**, and `rustpython-vm = +"0.5"` in this crate's `Cargo.toml` used to pull in the full default set — +bakes `posix`/`_ctypes` (and `os`/`nt` on other platforms) into every +interpreter's *core* module registry, entirely independent of ever adding +the separate `rustpython-stdlib` crate. Confirmed by actually running it, +not by reading feature flags: with `host_env` on, `PY_EVAL +__import__('posix').getcwd()` and `PY_EVAL open('/etc/passwd').read()` both +worked — full filesystem read (and write) access from any script. + +**Fixed**: `Cargo.toml` now depends on `rustpython-vm` with +`default-features = false, features = ["compiler", "gc"]` — no `host_env`. +Re-verified the full matrix after the fix, all blocked +(`ModuleNotFoundError` or, for `open()`, `UnsupportedOperation`): +`os`, `posix`, `nt`, `subprocess`, `socket`, `ctypes`, `_ctypes`, `_socket`, +`_subprocess`, `_signal`, `pwd`, and both read and write through the +built-in `open()` — its `FileIO` backing turned out to depend on +`host_env`-gated code internally too, even though the `_io` module itself +is always registered. Confirmed no functional regression: `ocs.add_line` +and the full `examples/example.py` (including both real constraints) still +run correctly with `host_env` off. + +What's left in `sys.modules` after that fix, for a fresh interpreter — +audited exhaustively, not just "fewer than before": `_ast`, `_codecs`, +`_frozen_importlib`, `_imp`, `_io`, `_thread`, `_typing`, `_warnings`, +`_weakref`, `builtins`, `codecs`, `encodings_ascii`, `encodings_utf_8`, +`ocs`, `sys` — core language/import/codec plumbing needed to run Python at +all, plus `ocs`. None of it reaches the filesystem, network, or another +process. + +**One known, accepted residual gap, not silently ignored: `_thread` is +real and functional** (`_thread.start_new_thread` exists and is callable, +verified — not a stub). It's registered unconditionally in `rustpython-vm` +0.5.0's `stdlib/mod.rs` with no feature guard at all — not `host_env`, not +even its own `threading` Cargo feature — so there is no dependency-level +way to remove it short of patching RustPython itself, which is out of +scope here. It grants no filesystem/network/process access (everything +that would matter for *that* is confirmed blocked above); the realistic +risk is a script spawning a thread that busy-loops or otherwise misbehaves +inside the plugin's own process — a resource/stability concern local to +the already crash-isolated plugin process (per `plugin-architecture.md`'s +out-of-process design), not a host compromise or data exfiltration path. +Deleting `_thread` from `sys.modules` after interpreter setup was +considered and rejected as a fix: the module stays *registered* at the VM +level regardless, so a script could just re-import it — the deletion would +be security theater, not a real control. + +## Commands + +| Command | Description | +|---------|-------------| +| `PY_EVAL ` | Evaluate one Python expression and print the result (or error) to the command line. Example: `PY_EVAL 1 + 1`. | +| `PY_RUN ` | Run a `.py` file as a script (statements, not just one expression). Reports an error the same way as `PY_EVAL` (missing file, syntax error, exception) — never a silent no-op. | + +## The `ocs` module + +Pre-bound as `ocs` in both commands — no `import` needed (and for `PY_EVAL`, +none is possible: it compiles in `Mode::Eval`, which only accepts a single +expression, not statements). Phases 1.2 (read), 1.3 (2D write) and part of +1.4 (`Run Script…`) of this plugin's `ROADMAP.md`. + +| Function | Returns | +|----------|---------| +| `ocs.selection()` | List of the selected entities' handles (`int`), as of the last selection change — see note below. | +| `ocs.get(handle)` | Dict of `{handle, kind, layer, point}` for that entity, or `None` if it doesn't exist. `point` is `{x, y, z}` or `None` for non-point entities. | +| `ocs.get_text(handle)` | For single-line TEXT, returns its active alignment point, OCS normal, rotation, direction, and height; otherwise `None`. Reads the host's full document snapshot. | +| `ocs.text_entities()` | Lists top-level TEXT/MTEXT entities as `{handle, kind, value}` dictionaries from the active document. | +| `ocs.block_references()` | Lists top-level INSERT entities as `{handle, name, effective_name, count, is_xref, is_dynamic, visibility}` dictionaries; `count` includes MINSERT row × column multiplicity and unresolved visibility is `None`. | +| `ocs.layers()` | Lists active-document layer names, off/frozen/locked flags, and top-level entity counts. | +| `ocs.set_entity_layer(handle, layer_name)` | Moves an existing top-level entity to an existing layer, preserving its handle and layout ownership. | +| `ocs.export_layer_dwg(layer_name, output_path)` | Writes a new DWG containing one layer's top-level entities plus needed block definitions; requires an existing directory and refuses overwrite. | +| `ocs.current_layer()` | Returns the active document's current layer name (`CLAYER`). | +| `ocs.layout_for_entity(handle)` | Returns an entity's owning layout name, tab order, and paperspace sheet count; `None` when unresolved. | +| `ocs.viewport_model_outline(handle)` | Returns four XY modelspace corners and elevation for a rectangular, unclipped +Z paperspace viewport; otherwise `None`. | +| `ocs.add_closed_polyline(vertices, elevation)` | Adds a closed lightweight polyline from at least three finite `[x, y]` vertices. | +| `ocs.text_box_vertices(handle, padding)` | Returns an approximate rotated XY box for left/baseline +Z TEXT; otherwise `None`. | +| `ocs.update_closed_polyline(handle, vertices, elevation)` | Replaces a closed lightweight polyline's vertices/elevation, preserving its handle and common properties. | +| `ocs.copy_model_to_paper(handles, viewport_handle)` | Copies planar modelspace LINE/ARC/CIRCLE/LWPolyline geometry through a rectangular +Z viewport to its paperspace layout. | +| `ocs.mirror_xy_entities(handles, axis_handle, erase_source)` | Mirrors planar LINE/ARC/CIRCLE/LWPolyline entities across an existing XY LINE; optionally removes sources in the same undo group. | +| `ocs.mirror_xy_about_line(handles, point, tangent, erase_source)` | Mirrors planar curves across the XY line through a point in a tangent direction; supports curved-axis midpoint workflows. | +| `ocs.break_xy_line(handle, first, second)` | Removes the span between two interior XY points on a LINE, preserving its first fragment's handle and properties. | +| `ocs.break_xy_arc(handle, first, second)` | Removes the angular span between two interior XY ARC points, retaining the first fragment's handle. | +| `ocs.break_xy_circle(handle, first, second)` | Replaces an XY CIRCLE with the complementary ARC after removing the counterclockwise span from first to second. | +| `ocs.break_xy_polyline(handle, first, second)` | Removes a path interval from an open, straight, zero-width XY LWPolyline, keeping the first fragment's handle. | +| `ocs.align_xy_entities(handles, source, destination, source_direction, target_direction, copy_mode)` | Rigidly moves and rotates planar LINE/ARC/CIRCLE/LWPolyline entities in XY; `copy_mode=True` adds transformed copies instead of changing originals. | +| `ocs.block_insert_point(handle)` | Returns `[x, y, z]` for an INSERT, or `None`. | +| `ocs.block_bounds_xy(handle)` | Returns `[xmin, ymin, xmax, ymax]` for a simple unrotated single INSERT whose definition contains only LINE/ARC/CIRCLE/LWPolyline geometry; otherwise `None`. | +| `ocs.block_attributes(handle)` | Lists `{tag, value}` pairs for one INSERT, or `None` for another entity. | +| `ocs.set_block_attribute(handle, tag, value)` | Updates one INSERT attribute by case-insensitive tag, preserving the block's other data. | +| `ocs.set_text_value(handle, value)` | Replaces one TEXT/MTEXT entity's content, preserving other properties. Raises if the entity cannot be updated. | +| `ocs.set_mtext_mask(handle, mode, scale)` | Sets one MTEXT background mode (`off`, `mask`, or `fill`) and border scale; raises if invalid or not MTEXT. | +| `ocs.move_text(handle, x, y, z)` | Moves only a TEXT entity's active alignment point, preserving its other properties. Raises if it cannot update the entity. | +| `ocs.set_text_pose(handle, x, y, z, rotation)` | Moves and rotates one XY TEXT entity while preserving its other properties. | +| `ocs.get_area(handle)` | Returns `{area, point}` for a circle or straight, closed lightweight polyline in the world XY plane; otherwise `None`. `point` is suitable for an area-number label. | +| `ocs.get_curve_measure(handle)` | Returns `{length, point, rotation, tangent}` at half length for an XY line, circle, arc, or straight lightweight polyline; `tangent` is an XY unit vector. Otherwise `None`. | +| `ocs.line_endpoints(handle)` | Returns two `[x, y, z]` points for a nonzero XY LINE; otherwise `None`. | +| `ocs.polyline_segments(handle)` | Returns segment endpoint, width, length, and optional bulge-arc centre/radius dictionaries for an XY lightweight polyline; otherwise `None`. | +| `ocs.write_new_text_file(path, content)` | Writes a new UTF-8 report file at an explicit path during a `PY_` command. Refuses to overwrite or create parent directories. | +| `ocs.curve_samples(handle, segments)` | Returns equally spaced XY sample vertices, elevation, and closure for a line, arc, or circle; otherwise `None`. | +| `ocs.replace_with_polyline(handle, vertices, elevation, closed)` | Replaces a line, arc, or circle in place with a lightweight polyline, preserving the handle and common drawing properties. | +| `ocs.add_line(x1, y1, x2, y2)` | Adds a 2D line (z=0, layer `"0"`); returns its handle. | +| `ocs.add_circle(x, y, radius)` | Adds a 2D circle (z=0, layer `"0"`); returns its handle. | +| `ocs.add_arc(x, y, radius, start_deg, end_deg)` | Adds a 2D arc (z=0, layer `"0"`), angles in degrees; returns its handle. | +| `ocs.add_polyline(vertices, elevation)` | Adds an open 2D lightweight polyline. Each vertex is `[x, y, start_width, end_width]`; returns its handle. | +| `ocs.add_text_centered(value, x, y, z, height, rotation_radians)` | Adds middle-center aligned single-line text; returns its handle. | +| `ocs.add_text_left(value, x, y, z, height, rotation_radians)` | Adds left/baseline single-line text at its insertion point; returns its handle. | +| `ocs.add_mtext(value, point, height, width, rotation_radians)` | Adds top-left anchored MTEXT at `[x, y, z]` using Standard style; returns its handle. | +| `ocs.add_points(points)` | Adds many 3D points (each `[x, y, z]`) in one `HostApi::add_entities` batch call; returns the new handles in order. | +| `ocs.remove_entity(handle)` | Deletes one entity in the script's undo group; raises if the handle cannot be removed. | +| `ocs.add_table(rows, x, y, z, row_height, column_width)` | Adds a native table from a rectangular list of string rows; returns its handle. | +| `ocs.read_record(handle, app_name)` | Returns `{app_name, values}` for the XDATA record `app_name` on `handle`, or `None` if the entity or the record doesn't exist. See "XDATA" below for the `values` shape. | +| `ocs.write_record(handle, app_name, values)` | Attaches an XDATA record to `handle` for `app_name`, replacing any existing record for that application. Raises if `handle` doesn't exist. | +| `ocs.remove_record(handle, app_name)` | Removes the XDATA record for `app_name` from `handle`, if any. Returns `True` if a record was actually removed. | +| `ocs.command(cmd)` | Experimental only; absent from the portable default build. Requires unreleased host API support and can hang on OCS 2026.37. See historical notes below. | +| `ocs.select(handles)` | Experimental only; absent from the portable default build. Requires unreleased host API support. | +| `ocs.system_variable(name)` / `ocs.set_system_variable(name, value)` | Development-only `experimental-host-settings` feature. Reads or sets host-managed CLAYER (text) and SNAPANG (degrees) without nested command dispatch. Requires the Felix OCS fork's `plugin/host-model-api` branch; absent from the portable default build. | + +`add_line`/`add_circle`/`add_arc` take plain Python numbers — +`ocs.add_line(0, 0, 10, 10)` works with ints, not just floats. (Needed an +explicit fix: a bare `f64` `#[pyfunction]` parameter in RustPython, unlike +CPython's C-function argument parsing, does *not* implicitly coerce an +`int` — it raises `TypeError`. Using `rustpython_vm::function::ArgIntoFloat` +instead accepts anything with `__float__`/`__index__`, matching how a script +author actually writes coordinates.) + +### Historical command-replay experiment (not in the default build) + +The following records an earlier experiment. The bundled build does **not** +expose `ocs.command()` or `ocs.select()`. An OCS 2026.37 diagnostic found +nested `ocs.command("CLAYER ...")` could hang, so these +examples are not instructions to run against the released host. + +**`ocs.command()` + `ocs.select()` together can apply a real, persistent +constraint, entirely from a script** — this required two host changes +(`HostApi::run_command` and `HostApi::set_selection`, `ocs_plugin_api` +v0.2.1+ built from a worktree that isn't upstream yet — see the note at the +bottom of this file), not something achievable from the plugin alone. +Verified end-to-end against a real running host, not just "it compiles": +```python +ocs.select([line1_handle, line2_handle]) +ocs.command("PCONSTRAINT") +``` +run as a plain `.py` file via `PY_RUN` — no automation-API help, no manual +pre-selection — ran the actual geometric solver and visibly adjusted both +lines' coordinates to be genuinely parallel (checked numerically: +direction-vector cross product ≈ 0 after, nonzero before). A real, +persistent `PCONSTRAINT` object. Same result verified for `TCONSTRAINT` +(circle-to-line tangency): center-to-line distance equals the radius +exactly after. `examples/example.py` does both — its "parallel" lines and +"tangent" circle are real constraint objects, not geometry computed once. + +Why two calls, not one: constraint commands read a prior *selection* (a +`"Select objects:"` prompt), not picks fed as command-line tokens the way +`LINE`'s points are — feeding `ocs.command("PCONSTRAINT {h1} {h2}")` with +handles as trailing tokens starts the command but ends in "Command +cancelled", not a constraint. `ocs.select()` sets that selection state +first; `ocs.command()` then reads it. + +**Not every command completes cleanly this way — `ALIGN` is the found +counterexample.** `ocs.command()`'s point-feeding sends exactly *one* +trailing Enter after the fed tokens, to finish the command. `ALIGN` needs +two after its point pairs (skip the optional 3rd source/destination pair, +then answer a "Scale objects? [Yes/No]" question) — so +`ocs.command("ALIGN 0,0 20,20 2,0 22,22")` **does** apply the actual +move/rotate transform correctly (verified: the entities land at the right +transformed coordinates), but leaves that Scale prompt open instead of +cleanly finishing the command. Not attempted in `examples/example.py` — +shipping something that half-completes seemed worse than leaving it out. +A general fix would need `ocs.command()` to accept a trailing-Enter *count* +(or an explicit terminator convention) instead of always exactly one; +not implemented. + +**`ocs.command()` cannot invoke itself or any other plugin's command** — +only built-ins. This call is a nested plugin→host request arriving while the +plugin's own `dispatch()` is still running (its one runner thread is +blocked waiting for this exact call to return); routing it back through +normal plugin dispatch would hand that same plugin (or any plugin) a second +`Dispatch` request it has no free thread to answer, deadlocking both sides. +Found this the hard way — `ocs.command("LINE 0,0 10,10")` hung completely +before the host-side fix. `run_command` skips plugin dispatch entirely, by +design, not as an oversight. + +### Which built-in commands `ocs.command()` can reach + +`ocs.command()` goes through the exact same dispatcher as the interactive +command line — `HostApi::run_command` → the host's +`run_command_line_no_plugin_reentry` → `dispatch_command_inner(cmd, +allow_suggest=false, try_plugins=false)` — so almost everything typeable in +OCS also works from a script, minus one deliberate carve-out. + +**Reachable: the full built-in command set**, spread across ten dispatch +families in the host's `src/app/commands/` (`fileops`, `layers`, `blocks`, +`draw`, `dim`, `inquiry`, `view`, `layerprops`, `styleprops`, `display`). +That covers everything AutoCAD-compatible the host implements natively — +drawing entities and constraints (`LINE`, `CIRCLE`, `ARC`, `POLYLINE`, +`MOVE`, `ROTATE`, `ALIGN`, `PCONSTRAINT`, `TCONSTRAINT`, in `dispatch_draw`), +dimensions (`DIMLINEAR`, `DIMALIGNED`, `DIMSTYLE`, ...), blocks (`BLOCK`, +`INSERT`, `WBLOCK`, `ATTSYNC`, ...), layers/properties (`LAYER`, `LAYISO`, +`LAYDEL`, `CHPROP`, `PROPERTIES`, ...), file ops (`NEW`, `OPEN`, `SAVE`, +`SAVEAS`, `EXPORT...`, `PLOT`, ...), inquiry (`LIST`, `DIST`, `AREA`, +`DBLIST`, ...), view (`ZOOM`, `PAN`, `VIEW`, `REGEN`, ...), and +directly-settable system variables typed as commands (`PICKBOX 5`, +`MIRRTEXT 1`, `LUPREC 4`, ...). Aliases still resolve too (`ocs.command("L +0,0 10,10")` works the same as `LINE`), since alias expansion happens before +the plugin-skip. + +**Not reachable: any plugin-provided command, including this plugin's own** +(`PYTHONSHELL`, `PY_EVAL`, `PY_RUN`) — see the paragraph above for why. This +is the *only* restriction versus the interactive command line; there is no +narrower allow-list beyond it. `allow_suggest=false` also means no +autocomplete/fuzzy-matching of partial verbs (unlike typing `BAC` and +pressing Enter for `BACKGROUND`) — a script must spell out the exact command +name or a real alias. + +Usage notes already verified end-to-end (see the constraint example above +and `examples/example.py`): + +- Multi-point/interactive commands take their args inline on one string, + exactly as if typed then Enter: `ocs.command("LINE 0,0 10,10")`. +- A command that reads a *prior selection* rather than inline picks + (constraint commands are the concrete case) needs `ocs.select(handles)` + called first — `ocs.command()` alone can't feed a "Select objects:" + prompt. +- `ALIGN` needs two trailing Enters (skip the optional 3rd point pair, then + answer the Scale prompt); `ocs.command()` only ever sends one, so it + applies the transform but leaves that prompt dangling — see above. + +For the exhaustive, exact list rather than this summary, the source of +truth is the `inventory::submit!(CommandRegistration { names: &[...] })` +blocks scattered across the host's `src/app/commands/*.rs` (105 files +register at least one command as of this writing); the largest single block +— everything without its own interactive command module — is +`src/app/commands/mod.rs`'s command-line autocomplete registry. Since it's +the same dispatcher, OCS's own command-line autocomplete is also a live +index of what `ocs.command()` can run. + +**One `push_undo` group per script, not per `ocs.add_*` call.** A `PY_RUN` +script that calls `ocs.add_line` ten times undoes as one step. Verified +end-to-end: a two-line script undoes both lines in a single `undo`. +Implemented via `host_ctx::ensure_undo_started`, called lazily by the first +write in a given `PY_EVAL`/`PY_RUN` — a read-only script never touches the +undo stack. Note: `ocs.command()` does *not* go through this — a built-in +command manages its own undo the same way it would if typed, so `ocs.command` +calls are not folded into the script's shared undo group. + +**`ocs.selection()` is a cache, not a live query.** `HostApi` has no +synchronous "give me the current selection" call — only a best-effort +`SelectionChanged` / `SelectionChangedV4` notification. It reflects the +selection as of the last time it changed, not necessarily the instant the +script calls it. + +That cache is populated via `BuiltinPlugin::on_notification`, **not** by +calling `HostApi::try_recv_notification()` from `dispatch()`. Found the hard +way: `ocs_plugin_api::runner`'s own V4 event loop (`run_v4` in `runner.rs`) +drains that queue itself on every iteration and forwards each notification to +`on_notification` *before* a `Dispatch` request ever reaches `dispatch()` — so +polling it from inside `dispatch` always finds it empty. This plugin +overrides `on_notification` and stashes the latest selection in a +process-wide cache (`selection_cache.rs`); `ocs.selection()` reads that. +Verified end-to-end against a real host build: select an entity, then +`PY_EVAL ocs.selection()` returns its handle. + +Each `PY_EVAL`/`PY_RUN` runs in a fresh interpreter (no stdlib, no session +state across calls) — REPL/session persistence is an open question for a +later phase. + +**Typing or pasting `PY_EVAL`/`PY_RUN` directly into the interactive command +line is broken for anything beyond trivial arithmetic — a host issue, not +this plugin.** `Message::CommandInput` in `src/app/update/mod.rs` (this +repo) uppercases the *entire* input line on every keystroke *and* on paste, +and auto-submits the instant it contains a space (both deliberate, for +classic single-line CAD commands like `LINE 0,0 10,10`). That mangles case +(`ocs.add_line` → `OCS.ADD_LINE`, a `NameError`) and truncates arguments +(`PY_EVAL ` submits before you finish typing the expression) for *any* +multi-word, case-sensitive plugin argument — automation +(`ocs_execute`/`--mcp`) bypasses this UI path entirely, which is why it +never surfaced in this session's own testing until a live user tried typing +it by hand. **`Run Script…` (below) sidesteps this correctly** — the host +authors already solved the identical problem for file paths via +`ModuleEvent::PluginFileDialog`, which dispatches with original case intact. +There is no equivalent bypass for `PY_EVAL` today; typed/pasted `PY_EVAL` +only survives if it has no spaces and no case-sensitive identifiers. + +## Ribbon + +Two tools in the "Tools" group: + +| Tool | Behavior | +|------|----------| +| Eval 1+1 | Dispatches `PY_EVAL 1 + 1` directly — a smoke test. | +| Run Script… | `ModuleEvent::PluginFileDialog` opens a native "pick a `.py` file" dialog; on selection the host dispatches `PY_RUN ` back to the plugin. Cancel does nothing. | + +**A real script manager UI (list/run/save scripts, or a dockable REPL +console) is not possible with today's `ocs_plugin_api`.** This was an open +question in `ROADMAP.md` — now answered by reading +`ribbon.rs`: `CadModule` exposes exactly one hook, `ribbon_groups()`, which +returns a **static** set of ribbon buttons/dropdowns (`RibbonItem`). There is +no API for a plugin to host a persistent custom panel, list dynamic content, +or render arbitrary UI — the ribbon vocabulary is deliberately "plain data +the host renders," not a widget toolkit. Getting a script list or REPL panel +would need a new `HostApi`/`CadModule` capability, i.e. an `ocs_plugin_api` +version bump — a host-side feature, out of scope for a plugin repo. `Run +Script…` (above) is the most a plugin can do today: reuse the host's own +native file picker via the existing `PluginFileDialog` event, which was +already there for other plugins to import files. + +## lisp2py — AutoLISP macro transpiler + +`../lisp2py/` (an independent sibling workspace — zero dependency on this +crate or `ocs_plugin_api`) is a **best-effort, offline, source-to-source** +translator from AutoLISP (`.lsp`) to the `ocs.*` Python dialect documented +above. Not an interpreter: it never executes AutoLISP, and its output is a +draft a human reviews and finishes, then runs through `PY_RUN` like any +other script. This is Phase 2.2/2.3 of `ROADMAP.md` — see +[`../lisp2py/README.md`](../lisp2py/README.md) +for the full pipeline, the corpus-measured builtin coverage, and every +known limitation; this is just the quick-start. + +```sh +cargo run --manifest-path ../lisp2py/Cargo.toml --bin ocs-lisp2py -- macro.lsp > macro.py +# then, inside OCS: +PY_RUN macro.py +``` + +(Or pipe source on stdin: `cat macro.lsp | cargo run --manifest-path ../lisp2py/Cargo.toml --bin +ocs-lisp2py -- > macro.py`.) + +Anything the translator can't confidently map — an unrecognized builtin, a +construct with no Python equivalent — becomes a `_todo_manual_port(...)` +call in the generated file (raises `NotImplementedError` if actually run) +plus a warning printed to stderr, never a silent guess. `vla-*`/`vlax-*` +(ActiveX/COM) and `vlr-*` (reactors) are explicitly out of scope, for the +same reason as everywhere else in this plugin's design: COM doesn't run +under AutoCAD for Mac either. Builtin coverage was widened using measured +call-frequency data from a real AutoLISP corpus sample rather than +guesswork, biased toward 2D geometry (`angle`/`distance`/`polar`) over +3D/solid-modeling per current project priority. + +## XDATA + +`ocs.read_record`/`ocs.write_record`/`ocs.remove_record` wrap +`HostApi::read_record`/`write_record`/`remove_record` — already implemented +by the host (unlike `run_command`/`set_selection`, these needed no host +change or `[patch]` entry). A record is `{"app_name": str, "values": [...]}`; +each value is `{"kind": str, "value": ...}`. Supported kinds and their +Python `value` shape: + +| `kind` | Python `value` | +|---|---| +| `String`, `ControlString`, `LayerName` | `str` | +| `BinaryData` | `bytes` | +| `Handle` | `int` | +| `Point3D`, `Position3D`, `Displacement3D`, `Direction3D` | `[x, y, z]` (`float`) | +| `Real`, `Distance`, `ScaleFactor` | `float` | +| `Integer16`, `Integer32` | `int` | + +```python +ocs.write_record(handle, "MYAPP", [ + {"kind": "String", "value": "part-042"}, + {"kind": "Integer32", "value": 7}, +]) +record = ocs.read_record(handle, "MYAPP") # {"app_name": "MYAPP", "values": [...]} +ocs.remove_record(handle, "MYAPP") +``` + +This `{app_name, values}`/`{kind, value}` shape is deliberately the same one +`schoeller/ocs_python_repl` (a separate, out-of-process PyO3-based Python +plugin for OCS) uses in its `ocs.doc.read_record`/`write_record`, so a +script's XDATA handling ports between the two plugins unchanged. `write_record` +starts the script's shared undo group the same way `ocs.add_*` does (see +below); `remove_record` only starts it when a record actually existed to +remove. + +## Build note: bundled host API + +`Cargo.toml` uses the repository-relative `ocs_plugin_api` crate. Packaging +builds the plugin with the same compiler and CAD codec as the host and stages +matching metadata beside the library. The `experimental-command-replay` Cargo +feature keeps the old bridge code available, but the tested nested-command +path can hang. Do not enable it for production. diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md new file mode 100644 index 000000000..10da6d43c --- /dev/null +++ b/plugins/opencad-python/README.md @@ -0,0 +1,120 @@ +# opencad-python + +Python scripting for Open CAD Studio, shipped as a bundled first-party plugin. Embeds +[RustPython](https://github.com/RustPython/RustPython) and exposes an `ocs` +module wrapping `HostApi`, so `PY_`-prefixed commands can run Python against +the open document. + +The crate remains an independent process plugin under `plugins/` so Python +cannot crash the editor process and all writes still cross the versioned +`HostApi`. Its API dependency is repository relative, and the macOS packaging +flow builds and places it in the application bundle automatically. + +Full design and phased plan: `ROADMAP.md` in this directory. +Command reference, the `ocs` module's functions, and every real gap/limit +found while building this: `PLUGIN.md` in this directory. + +## Status + +The API v7 build provides `PY_EVAL`/`PY_RUN`, the read/write `ocs` module, and +the ribbon integration; these have live OCS 2026.37 tests. Experimental +`ocs.command()`/`ocs.select()` constraint work +is documented in `PLUGIN.md` and `examples/example.py`, but those functions +are not in the default build: their host APIs are unreleased, and nested +command replay can hang. The general document model is built through the +`experimental-host-model` feature until that name is retired in a later API +cleanup. + +## Files + +| File | Purpose | +|------|---------| +| `Cargo.toml` | `cdylib` crate depending on `ocs_plugin_api` (`host` feature) and `rustpython-vm` (stdlib disabled — see `PLUGIN.md`'s sandboxing section) | +| `src/lib.rs` | manifest + `CadModule` ribbon + `BuiltinPlugin` + `export_plugin!` + `PY_EVAL`/`PY_RUN` dispatch | +| `src/ocs_module.rs` | the `ocs` Python module (`selection`, `get`, drawing access; experimental `command`/`select` are feature-gated) | +| `src/host_ctx.rs` | thread-local bridge from `dispatch()`'s `HostApi` into the `#[pymodule]` native functions | +| `src/selection_cache.rs` | process-wide selection cache, populated via `on_notification` | +| `src/document_model.py` | API v7 Python document view and transaction context manager | +| `src/event_cache.rs`, `src/input_cache.rs` | bounded event queue and interactive-pick results | +| `tools/stage-bundled.sh` | build and stage the matching API v7 package | +| `plugin.toml` | metadata read by the host (mirrors the manifest) | +| `examples/example.py` | a script demonstrating shapes plus two real, persistent constraints | +| `../../.github/workflows/release.yml` | bundles the cdylib into the macOS application | +| `PLUGIN.md` | command reference, the `ocs` module, and every real gap/limit found | + +## Build + +```sh +cargo build --release --locked --features experimental-host-model \ + --manifest-path plugins/opencad-python/Cargo.toml +``` + +## Stage the bundled plugin + +The staging script builds against the in-tree `ocs_plugin_api`, writes exact +compiler and CAD codec metadata, and creates the directory layout consumed by +bundled plugin discovery. + +```sh +bash plugins/opencad-python/tools/stage-bundled.sh /path/to/stage-directory +``` + +Add `--debug` for a faster local build. Build with the same Rust toolchain as +the OCS binary; the host checks the compiler version when loading plugins. + +```python +doc = ocs.active_document +line = doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, + end={"x": 10, "y": 0, "z": 0}) +line = doc.entities[handle] +print(line.start, line.end, line.layer, line.coverage) +with doc.transaction("Move line"): + line.start = (0, 0, 0) + line.end = (10, 0, 0) + +doc.selection = [line] +token = doc.request_point("Pick destination") +# After the host has collected the click, run another script: +result = doc.poll_input(token) +events = doc.poll_events() +doc.delete_entity(line) +``` + +`doc.entities` iterates every document entity. The generated Python schema +currently edits Point, Line, Circle, Arc, Ellipse, Polyline, Polyline2D, +Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, +Insert, Tolerance, Shape, AttributeDefinition, AttributeEntity, and Hatch. +Insert block references support edits to placement and transform +properties; their block name is readable and creation is not yet exposed. +Other canvas kinds expose +handle, kind, and layer; their layer can be changed in a transaction. Internal +and opaque kinds remain read-only. Each +mapped entity reports its actual readable/editable keys through `coverage`. +The host-owned catalog classifies all 48 entity variants and tracks every +traced field, including fields marked `unmapped`. Query it with +`doc.coverage()` or `doc.coverage("Line")`; each row includes source field, +type, access, and current validation status. Geometry edits remain limited to +the 22 mapped kinds. +For any entity, `entity.snapshot` returns a detached dictionary of its raw +CAD fields, including `snapshot["common"]` and kind-specific nested values. +It can inspect an unmapped kind; editing the dictionary does +not commit changes to the drawing. +Point and entity requests return a token because `PY_RUN` uses a fresh +interpreter for each invocation; `poll_input` reports pending, picked, or +cancelled on a later run in the same drawing tab. Another tab cannot consume +that token. `doc.selection` preserves the exact requested handle order and +rejects duplicate or absent handles. `poll_events` drains tab-scoped drawing, selection, +and active-command state changes. Each tab retains up to 256 events; an +`{"type": "overflow", "dropped": n}` event means older events were lost, so +refresh the document and selection before acting on cached state. + +## Test locally as a bundled plugin + +```sh +bash plugins/opencad-python/tools/stage-bundled.sh \ + work/bundled-plugins/opencad.python --debug +OCS_BUNDLED_PLUGINS_DIR="$PWD/work/bundled-plugins" cargo run -- --new +``` + +The macOS release and signed packaging scripts perform this staging inside +`OpenCADStudio.app/Contents/Resources/plugins/opencad.python` automatically. diff --git a/plugins/opencad-python/ROADMAP.md b/plugins/opencad-python/ROADMAP.md new file mode 100644 index 000000000..1d13796d7 --- /dev/null +++ b/plugins/opencad-python/ROADMAP.md @@ -0,0 +1,393 @@ +# Open CAD Studio — Python Scripting & AutoLISP Migration Plan + +**Status:** Phase 1 done and host-verified; Phase 2 (the `lisp2py` +transpiler) has progressed well past what's checked off below — see +[`../lisp2py/README.md`](../lisp2py/README.md) and +[`../../PLUGIN_DEVELOPMENT_RECIPE.md`](../../PLUGIN_DEVELOPMENT_RECIPE.md) for +current status. Kept here as the original planning document and Phase 1 +build log, moved from `OpenCADStudio`'s `docs/` (where it was originally +drafted, before this repo held the plugin work) unchanged except for +cross-repo reference fixes below. + +**Author:** Open CAD Studio contributors +**Date:** September 2026 + +This document plans embedded Python scripting for Open CAD Studio as an +**external plugin** (not a host change), followed by a best-effort AutoLISP → +Python transpiler built on top of it. It supersedes the host-embedded design +sketched in an earlier draft of this document: scripting fits OCS's existing +plugin model better than it fits inside the host. OpenCADStudio's +`docs/plugin-architecture.md` explicitly lists "sandboxed scripting +(Python/Lua)" as a non-goal of the external-plugin *system* itself — this +plan doesn't touch that system, it just builds a plugin that happens to +embed an interpreter, same as any plugin embeds whatever dependency it +needs. + +**Why a plugin, not a host feature:** + +| | Host-embedded | Plugin (this plan) | +|---|---|---| +| Requires OCS core changes / Hakan's buy-in | Yes | No — ships from an independent repo, installed via the existing Plugin Manager | +| Crash isolation | Relies on RustPython's memory safety alone | Gets OS-process isolation for free (plugins already run out-of-process) | +| Distribution | Bundled into every OCS release, forever | Opt-in install, versioned independently, can iterate fast | +| Cost | New maintenance burden on the host | Same ABI-pinning cost every plugin already has (see Phase 1, Risk) | + +--- + +## Phase 1 — Python scripting plugin + +**Goal:** a working bundled `opencad-python` plugin (`cdylib`) that embeds +RustPython and exposes an `ocs` module wrapping `HostApi` while retaining the +out-of-process runner boundary. + +### 1.1 — Scaffold + +- [x] New repo from OpenCADStudio's `docs/plugin-template/`; `plugin.toml` + + `Cargo.toml` pinning `ocs_plugin_api` (`host` feature) and matching the + target host's `acadrust_source` / `rustc_version` (see Risks). Scaffolded + locally at `~/Documents/MacApps/opencad-python` (this repo — no remote + yet at the time, not pushed anywhere). +- [x] Add RustPython as a dependency; confirm it builds cleanly as part of a + `cdylib` on **macOS** (arm64) — Windows/Linux still unverified. Used + crates.io `rustpython-vm = "0.5"` (not the `rustpython` facade crate; + matches how the upstream `examples/hello_embed.rs` embeds it). Builds + clean, no warnings: `libopencad_python.dylib`, 17MB, ~80s from a cold + `cargo clean` release build. Exports the expected + `ocs_plugin_register` / `ocs_plugin_api_version` C-ABI symbols. A + `PY_HELLO` command runs a real RustPython smoke test (compiles and + evaluates `1 + 1` via `Interpreter::without_stdlib`) — confirms the + interpreter actually runs inside the cdylib, not just that it links. + One API gap vs. the upstream example: 0.5.0's `Vm::compile` takes an + owned `String` filename (not `&str`), and `CompileError` has no + `into_pyexception` method — worked around by matching on the `Result` + directly instead of using `?` with `PyResult`. + +### 1.2 — Minimal `ocs` module (read-only) + +- [x] `ocs.selection()` → list of entity handles. Implemented as a cache, not + a live query — `HostApi` has no synchronous "current selection" call + (only a best-effort `SelectionChanged`/`SelectionChangedV4` + notification). Populated via `BuiltinPlugin::on_notification` into a + process-wide static (`selection_cache.rs`) — see the host-runtime + verification note below for why `dispatch`-time polling doesn't work. + Documented as a real limitation in `PLUGIN.md`, not silently papered + over. +- [x] `ocs.get(handle)` → read-only geometry/property access, via + `HostApi::document_reader()` (zero-copy) + `for_each_entity`, matching + by handle. Returns `{handle, kind, layer, point}` or `None`. +- [x] `PY_EVAL ` command (`command_prefixes = ["PY_"]`): evaluate one + inline expression, print the result via `host.push_output`. Runs a + fresh, stdlib-free `Interpreter` per call with only the `ocs` native + module registered (`Interpreter::builder(...).add_native_module(...)`) + — no `import os`/`socket`/etc. is even possible since no stdlib is + loaded, which happens to satisfy most of §1.5's sandboxing goal already + as a side effect of keeping this phase minimal, not a deliberate + Phase 1.5 pass yet. +- [x] Errors: both compile errors (`CompileError`, which implements + `Display` directly — no `into_pyexception` needed) and runtime + exceptions (formatted as `": "`, no traceback yet) + go to `host.push_error`. +- [ ] Bridging `host: &mut dyn HostApi` into the `#[pymodule]` native + functions needed a thread-local raw-pointer scratch slot + (`host_ctx.rs`) with a documented safety argument, since + `add_native_module` wants `&'static PyModuleDef` — there's no closure + capture available to a native pyfunction. Worth a second pair of eyes; + it's sound as written (guard clears the slot before the real borrow + ends, single-threaded, non-reentrant) but is the one genuinely + load-bearing `unsafe` block in the plugin so far. +- [x] Verified: `cargo build`/`--release` clean, zero warnings, against the + real `ocs_plugin_api`/`rustpython-vm` 0.5.0 APIs (not guessed — traced + through the actual crate sources for `HostApi`, `DocumentReader`, + `InterpreterBuilder`, `ToPyObject`/`DictKey` impls, etc.). +- [x] **Host-runtime verified**, not just "compiles": built the actual OCS + host (debug, ~1m43s), installed the plugin, drove it through the real + `--mcp` automation API with an isolated `$HOME` (so the test couldn't + collide with an already-running instance — see below, that mistake cost + real time). Confirmed working end-to-end: + - `PY_EVAL 6 * 7` → `"42"`. + - `PY_EVAL 1 / 0` → `"ZeroDivisionError: division by zero"` via + `host.push_error` — not a silent no-op. + - `ocs.get(handle)` on a drawn `LINE` → correct + `{'handle': 98, 'kind': 'line', 'layer': '0', 'point': None}`. + - Along the way found and fixed a real bug: `PY_EVAL`'s `ocs` name was + never bound in scope (scripts would need `import ocs`, impossible + under `Mode::Eval`, which only accepts one expression). Fixed by + `vm.import("ocs", 0)` + `scope.globals.set_item` before compiling — + `ocs` is now pre-bound, no import needed or possible. + - **Found and fixed a second real bug, this one plugin-side, not the + host's**: `ocs.selection()` always returned `[]`. Root cause (found by + temporarily instrumenting the host, then reverted — no host changes + were needed): `ocs_plugin_api::runner`'s own V4 event loop (`run_v4` + in `runner.rs`) drains `HostApi::try_recv_notification()` on every + iteration and forwards each notification to + `BuiltinPlugin::on_notification` — *before* a `Dispatch` request ever + reaches `dispatch()`. Polling `try_recv_notification()` from inside + `dispatch` (what the plugin did) therefore always sees an empty + queue; the host's `SelectionChangedV4` broadcast + (`notify_plugins_selection_changed` / + `publish_selection_changed_v4` in `src/plugin/v4_support.rs`) was + firing correctly the whole time. Fix: override `on_notification` and + stash the selection in a process-wide cache instead + (`opencad-python`'s `selection_cache.rs`); `dispatch` and + `ocs.selection()` just read that. Verified end-to-end: select an + entity, then `PY_EVAL ocs.selection()` returns its handle. + - Worth folding into this doc's guidance for anyone writing a V4+ + plugin: **notifications must be consumed via `on_notification`, not + by polling `try_recv_notification()` from `dispatch`** — the stock + runner loop already drains that queue for you. + - **Testing pitfall worth remembering**: `--mcp` doesn't run headless — + it's a thin JSON-RPC client that reuses any already-running OCS + instance it finds under `$HOME/Library/Application Support/ + OpenCADStudio/automation` (`descriptors()` in `src/mcp.rs`), spawning + a fresh one only if none exists. First test run silently talked to + the *user's own already-running production app* (which obviously had + never seen this plugin — plugins load once at startup) instead of the + freshly built debug binary. Fix: override `$HOME` for the test + subprocess to a scratch directory, which isolates `config_dir()`, + `plugins_dir()` and the automation discovery directory all at once. + - macOS only, per current scope; Windows/Linux still unverified. + +### 1.3 — Write access + scripts + +- [x] `ocs.add_line(x1, y1, x2, y2)` / `ocs.add_circle(x, y, radius)` — 2D + only for now (z=0, layer `"0"`), wrapped so a whole script is **one** + `host.push_undo` group, not one per call (`host_ctx::ensure_undo_started`, + called lazily by the first write). Verified end-to-end: a `PY_RUN` + script that calls `ocs.add_line` twice undoes both lines in one `undo`. + Take `rustpython_vm::function::ArgIntoFloat`, not a bare `f64` — found + by actually running `ocs.add_line(0, 0, 10, 10)` (plain ints, the + natural way to write it) and hitting a `TypeError`: unlike CPython's + C-function argument parsing, a bare `f64` `#[pyfunction]` parameter in + RustPython does not implicitly coerce an `int`. +- [x] `PY_RUN ` to run a file (`Mode::Exec`, so statements are + allowed, unlike `PY_EVAL`'s `Mode::Eval`). Same error reporting as + `PY_EVAL`: a missing file or a raised exception is never a silent + no-op. Reusing the automation `run` op needed no transport changes, as + expected — `{"op":"run","cmd":"PY_RUN "}` just works. +- [x] `ocs.command(cmd)` to invoke existing built-in commands by name — + **implemented, required a host change**. Added `HostApi::run_command` + (default `Err` impl, no forced version bump) by reusing + `run_command_line` + `drive_headless_task` — the exact machinery + `--serve`/`--mcp` automation's `"run"` op already uses — via a new + `PluginRequest::RunCommand` and one match arm in + `handle_plugin_request`. Full detail: OpenCADStudio's + `docs/plugin-architecture.md`'s `HostApi` table, and this plugin's own + `PLUGIN.md`. + - **Found and fixed a real deadlock along the way**: the naive version + routed back through normal plugin dispatch, which — since this call + is a nested plugin→host request arriving *while* the calling + plugin's `dispatch()` is still running on its one runner thread — + sends that same plugin a second `Dispatch` it has no free thread to + answer. Both sides wait forever. Fixed by skipping plugin dispatch + entirely for this one call path + (`dispatch_command_no_plugin_reentry` / + `run_command_line_no_plugin_reentry`); confirmed via the full + `cargo test --lib` suite (1040 passed, 1 pre-existing unrelated + failure) that normal command-line/automation behavior is unchanged. + Consequence, by design: `ocs.command()` can reach built-ins only, + never another plugin's command. + - **Verified against a real, non-trivial case, not just `LINE`**: + pre-selecting two non-parallel lines then `ocs.command("PCONSTRAINT")` + ran the actual geometric solver and adjusted both lines to be + genuinely parallel — checked numerically (direction-vector cross + product ≈ 0 after), a real persistent constraint object. + - **Gap found, then closed same session**: constraint commands read a + prior *selection*, not picks fed as command tokens — feeding handles + as trailing `PCONSTRAINT` tokens starts the command but ends in + "Command cancelled", not a constraint. Added `HostApi::set_selection` + (same shape as `run_command`: new `PluginRequest::SetSelection`, + default-`Err` trait method, no forced version bump; validates every + handle exists before changing anything) and `ocs.select(handles)` on + top of it. Re-verified **fully scripted, no automation-API help**: a + plain `.py` file via `PY_RUN` calling `ocs.select([h1, h2])` then + `ocs.command("PCONSTRAINT")` produces the same genuinely-parallel + result as before, now reachable entirely from a script. Confirmed no + regressions via the full `cargo test --lib` suite both times (1040 + passed, 1 pre-existing unrelated font-fallback failure). +- [x] **`ocs.add_points(points)` and XDATA CRUD + (`ocs.read_record`/`write_record`/`remove_record`), ported from + reviewing `schoeller/ocs_python_repl`** — a separate, independently + maintained out-of-process PyO3-based Python plugin for OCS (real + CPython + IPython in a terminal, generated typed bindings) reviewed for + reusable ideas. Both needed **no host change**: `HostApi::add_entities` + (batch add) and `read_record`/`write_record`/`remove_record` (XDATA) + already existed in `ocs_plugin_api`, unused by this plugin until now — + unlike `run_command`/`set_selection` in §1.3, which needed new `HostApi` + methods. `add_points` batches many points into one + `host.add_entities()` call instead of looping `add_entity`, so the host + pays for undo/dirty/geometry-rebuild bookkeeping once per batch, not + once per point — the in-process analogue of `ocs_python_repl`'s + `ocs.doc.add_many(...)`, which batches to avoid one IPC round trip per + point instead. The XDATA functions deliberately reuse + `ocs_python_repl`'s exact `{app_name, values}`/`{kind, value}` record + shape (see this plugin's `PLUGIN.md`'s XDATA section) so a script's + XDATA handling ports between the two plugins unchanged. Verified with + unit tests that run a real RustPython interpreter (no live `HostApi`) + and assert that every new function gets all the way through its own + argument/dict parsing — down to every `XDataValue` kind — before + failing on the expected "not running inside a PY_ command" sentinel; + `cargo test`/`cargo clippy --all-targets` both clean. Not yet verified + against a real running host (the "host-runtime verified" bar the rest + of this phase holds itself to) — that still needs a live OCS build, as + noted for `ocs.command()`/`ocs.select()` above. + +### 1.4 — Usability + +- [x] **Open question resolved by reading `ribbon.rs` end to end, not + assumed**: a script manager UI (or a dockable REPL console) is **not + possible** with today's `ocs_plugin_api`. `CadModule` exposes exactly + one hook, `ribbon_groups()`, returning a *static* set of + buttons/dropdowns (`RibbonItem`) — there is no API for a plugin to host + a persistent custom panel or render dynamic content (a script list, a + REPL buffer, ...). That needs a new `HostApi`/`CadModule` capability — + an `ocs_plugin_api` version bump, a host feature request. Out of scope + for a plugin repo; documented as a real, checked answer in this + plugin's own `PLUGIN.md`, not left open. +- [x] `ModuleEvent::PluginFileDialog` (already exists) for "Run Script…": + opens a native file picker filtered to `.py`, and on selection the host + dispatches `PY_RUN ` back to the plugin — no new plugin code + needed beyond the ribbon entry, since `PY_RUN` already exists from + §1.3. Verified the plugin still loads and dispatches correctly with the + new ribbon entry; the file-picker interaction itself isn't automatable + headlessly over `--mcp` (no way to drive a native OS dialog over + JSON-RPC), so that specific interaction is unverified beyond "doesn't + crash". "Save Script As…" was dropped — with no in-app editor (which + needs the same missing custom-panel capability above), there's no + script content to save. + +### 1.5 — Sandboxing + +- [x] Audit: the `ocs` module is the *only* thing a script can call — done, + and **the earlier assumption was wrong, which is exactly why this was + worth auditing instead of assuming**. Phase 1.2/1.3 claimed "no stdlib + is loaded, so `import os`/`socket`/`subprocess` is already impossible" + — true for those three names, but RustPython 0.5.0's `host_env` Cargo + feature is **on by default** and bakes `posix`/`_ctypes` into every + interpreter's core module registry regardless of `rustpython-stdlib`. + Confirmed by actually running it: with `host_env` on, + `PY_EVAL __import__('posix').getcwd()` and + `PY_EVAL open('/etc/passwd').read()` both worked — full filesystem + read/write from any script. Fixed: `rustpython-vm` now builds with + `default-features = false, features = ["compiler", "gc"]`. + Re-verified the full matrix after: `os`/`posix`/`nt`/`subprocess`/ + `socket`/`ctypes`/`_ctypes`/`_socket`/`_subprocess`/`_signal`/`pwd` + all blocked, `open()` blocked for both read and write (its `FileIO` + needs `host_env` internally too, even though `_io` itself is always + registered). No functional regression — `ocs.add_line` and the full + `examples/example.py` (both real constraints) still run correctly. + Audited what remains exhaustively rather than assuming "fewer is + enough": 15 `sys.modules` entries, all core language/import/codec + plumbing plus `ocs`. One accepted, documented residual gap: `_thread` + is real and functional (verified callable) and has no feature guard + at all in this RustPython version — no dependency-level way to + remove it without patching RustPython. Grants no filesystem/network/ + process access; worst case is a resource/stability issue local to + the already crash-isolated plugin process. Full detail in this + plugin's own `PLUGIN.md`. + +### 1.6 — Distribution + +- [ ] Publish binaries per platform on GitHub Releases (matches every other + plugin's release shape already documented in OpenCADStudio's + `plugin-architecture.md`). +- [ ] Manual repo link works immediately (`owner/repo`, no OCS PR needed); + listing in OpenCADStudio's curated `plugins/registry.json` is a + separate, optional later step once the plugin is stable. + +**Risk to track continuously:** a plugin must match the host's exact +`rustc_version` and `acadrust_source` (API v4+ enforces this at load time). +Every OCS host release potentially requires a matching rebuild + republish of +this plugin, or users get a version-mismatch refusal. Budget for this as an +ongoing CI job (build matrix triggered off OCS's release tags), not a one-time +cost. + +--- + +## Phase 2 — AutoLISP → Python parser/translator + +**See [`../lisp2py/`](../lisp2py) — this phase has its own crate, README, +and builtin-coverage writeup now.** The sub-goals below are the original +scoping; §2.2/2.3 shipped with substantially wider builtin coverage than +first planned (measured from a real AutoLISP corpus sample, not just the +constructs listed here). + +**Goal:** a best-effort, offline source-to-source translator from `.lsp` to +the Phase 1 plugin's Python dialect. It is explicitly **not** an interpreter — +output is a draft a human reviews and finishes, not a guaranteed-correct +program. That's what makes this tractable where a live AutoLISP runtime isn't +(see the earlier discussion: the language core is easy, AutoCAD's builtin +library is the hard, open-ended part — a translator only needs a lookup table +for the builtins it recognizes, not a faithful live implementation of all of +them). + +### 2.1 — Corpus & scoping + +- [x] Pull a representative sample from [Lee Mac's routines](https://lee-mac.com/) + and the [Whole-Spec catalog](https://whole-spec.com/en/cad-tips/lisp-catalog/) + (157 curated routines) to find the actual distribution of constructs + used in real scripts — don't guess coverage priorities, measure them. +- [x] From that sample, explicitly decide and document what's out of scope: + **`vla-*`/`vlax-*` (COM/ActiveX)** — doesn't run in AutoCAD for Mac + either, so this isn't a new limitation — and **DCL dialogs** (no + renderer in OCS). Both get left as commented-out LISP with a + `# TODO: manual port needed` marker in the generated output, never + silently dropped. + +### 2.2 — Reader & core-language transform + +- [x] S-expression tokenizer/parser (standalone, no OCS runtime dependency). +- [x] AST-to-AST transform for the Lisp core: `setq`, `defun` + (`C:CMDNAME` → a Python function), `if`/`cond`, `lambda`/`function`, + arithmetic, string/list operations, `foreach`/`while`. + +### 2.3 — Builtin mapping table + +- [x] `entget` → `ocs.get(handle)`; `ssget` (no-arg) → `ocs.selection()`. +- [x] `command` sequences → `ocs.command("...")`. +- [x] `getvar`/`setvar` — confirmed no host system-variable surface exists; + documented as unsupported rather than guessed at. +- [x] Widened well past this original list via measured corpus frequency — + see `lisp2py`'s own README for the full table. + +### 2.4 — Validation loop + +- [ ] Run translated output through the Phase 1 plugin's interpreter against + the same corpus; every failure either fixes the mapping table or gets + added to the documented unsupported list. This is the actual acceptance + criterion for "done" — not 100% translation, but every gap being a known, + documented one. + +### 2.5 — Packaging + +- [x] Ship as a standalone CLI (`ocs-lisp2py file.lsp > file.py`) — needs + nothing from the OCS runtime, useful even to someone not running OCS yet. +- [ ] Optionally surface as an "Import AutoLISP Macro…" button in the Phase 1 + plugin's ribbon, calling the same conversion logic in-process. + +--- + +## Phase 3 — polish (not yet scoped in detail) + +- [ ] Decide the open question from Phase 1.4: dockable console REPL vs. + run-only. Depends on what `HostApi` can support without a version bump. +- [ ] Versioning story for the `ocs` module surface as it grows: same strict + `ApiVersion` gate as `ocs_plugin_api`, or a looser contract since scripts + aren't compiled artifacts and can tolerate softer compatibility breaks? +- [ ] Whether scripts should ever be able to register permanent ribbon tools + (leaning: no — keep that exclusive to compiled plugins, per + OpenCADStudio's `plugin-architecture.md`'s "one package, one entry + point" goal). + +--- + +## Reference + +| Piece | Location | +|-------|----------| +| Existing plugin architecture (non-goal note) | OpenCADStudio's `docs/plugin-architecture.md` | +| Plugin host API this plugin wraps | OpenCADStudio's `crates/ocs_plugin_api` (`HostApi` trait) | +| Plugin scaffold this was started from | OpenCADStudio's `docs/plugin-template/` | +| Existing automation/headless surface | OpenCADStudio's `src/app/control/transport.rs` | +| RustPython | https://github.com/RustPython/RustPython | +| AutoLISP corpus — Lee Mac | https://lee-mac.com/ | +| AutoLISP corpus — Whole-Spec catalog (157 routines) | https://whole-spec.com/en/cad-tips/lisp-catalog/ | diff --git a/plugins/opencad-python/build.rs b/plugins/opencad-python/build.rs new file mode 100644 index 000000000..0fe185325 --- /dev/null +++ b/plugins/opencad-python/build.rs @@ -0,0 +1,28 @@ +//! Generates `OUT_DIR/entity_crud.rs` — the `dict_to_entity`/`entity_to_dict` +//! conversion functions `ocs_module.rs` `include!`s — from `entity_manifest.json` +//! plus the type registry `ocs_plugin_api` embeds at its own build time (see +//! `crates/ocs_plugin_api::get_embedded_type_registry_json`, always compiled, +//! not gated behind the `host` feature — see that crate's `src/lib.rs`). See +//! `build/generate.rs` for the actual codegen. + +use std::env; +use std::path::PathBuf; + +include!("build/generate.rs"); + +fn main() { + println!("cargo:rerun-if-changed=entity_manifest.json"); + println!("cargo:rerun-if-changed=build/generate.rs"); + + let registry_json = ocs_plugin_api::get_embedded_type_registry_json(); + let registry: TypeRegistry = serde_json::from_str(registry_json) + .expect("ocs_plugin_api's embedded type registry should be valid JSON"); + + let manifest = Manifest::load(include_str!("entity_manifest.json")); + + let generated = generate_entity_crud(&manifest, ®istry); + + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set by cargo")); + std::fs::write(out_dir.join("entity_crud.rs"), generated) + .expect("failed to write generated entity_crud.rs"); +} diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs new file mode 100644 index 000000000..9867780a2 --- /dev/null +++ b/plugins/opencad-python/build/generate.rs @@ -0,0 +1,891 @@ +// Codegen for `ocs`'s generic entity conversion functions (`dict_to_entity` +// / `entity_to_dict`), driven by `entity_manifest.json` plus the type +// registry embedded in `ocs_plugin_api` (`get_embedded_type_registry_json`, +// traced by `crates/ocs_plugin_api/build.rs` in the OCS fork). +// +// Adapted from the RustPython-flavored port of `schoeller/ocs_python_repl`'s +// `build/generate.rs` (a PyO3-based generator over the same registry + +// manifest shape) — see `project_opencad_python_generic_entity_crud_plan.md` +// in this session's memory for the full design rationale. The two crates +// target different Python runtimes (PyO3/real CPython vs. RustPython), so +// the leaf conversion code differs, but the manifest schema and the +// registry-driven-with-overrides approach carries over directly. +// +// `include!`'d directly into `build.rs` (not a separate crate) so it shares +// `build.rs`'s own dependency graph without adding a workspace member. + +use std::collections::BTreeSet; + +use ocs_plugin_api::{FieldInfo, TypeId, TypeInfo, TypeKind, TypeRegistry}; +use serde::Deserialize; + +// ════════════════════════════════════════════════════════════════════════════ +// Manifest schema +// ════════════════════════════════════════════════════════════════════════════ + +#[derive(Debug, Deserialize)] +pub struct Manifest { + /// Entity kinds exposed to `ocs.add`/`ocs.update`/`entity_to_dict`. The + /// central, easy-to-widen filter for Phase 2 (see the plan doc). + pub type_filter: Vec, + /// `EntityCommon` fields promoted onto every entity dict. Only `handle` + /// and `layer` are supported today — the rest of `EntityCommon` (color, + /// line weight, XDATA, ...) has its own existing `ocs.*` accessors and is + /// deliberately out of scope for this generic path. + #[serde(default = "default_base_fields")] + pub base_fields: Vec, + #[serde(default)] + pub overrides: std::collections::BTreeMap, +} + +fn default_base_fields() -> Vec { + vec!["handle".to_string(), "layer".to_string()] +} + +#[derive(Debug, Deserialize, Default, Clone)] +pub struct EntityOverride { + /// Rust expression constructing a fresh value when this entity has no Default. + #[serde(default)] + pub rust_constructor: Option, + /// Existing entities can be inspected and patched, but `ocs.add` cannot + /// create one without host-side reference setup. + #[serde(default)] + pub update_only: bool, + #[serde(default)] + pub fields: std::collections::BTreeMap, +} + +#[derive(Debug, Deserialize, Default, Clone)] +pub struct FieldOverride { + /// Emit a getter but reject patches to this field. + #[serde(default)] + pub read_only: bool, + /// Rename the dict key for this field. + #[serde(default)] + pub python_name: Option, + /// Raw Rust *value* expression (not yet wrapped in `vm.new_pyobj`), + /// with `VAR` standing in for the entity's local variable name. + /// Requires `rust_setter` too. + #[serde(default)] + pub rust_getter: Option, + /// Raw Rust *statement* (semicolon-terminated), with `VAR` standing in + /// for the entity's local variable name and `dict`/`vm` bound as usual. + /// Requires `rust_getter` too. + #[serde(default)] + pub rust_setter: Option, + /// Drop this field from both directions entirely (e.g. a field whose + /// type this generator doesn't support yet, such as a tagged enum). + #[serde(default)] + pub exclude: bool, + /// `ocs.add()` errors if this field is missing rather than silently + /// defaulting it to zero/empty (Felix's Phase 1 review: "add should + /// reject missing required geometry rather than defaulting to zero"). + /// Only meaningful on the generic (non-`rust_getter`/`rust_setter`) + /// path — `ocs.update()` never requires a field (a merge onto an + /// already-valid entity never needs it re-specified). + #[serde(default)] + pub required: bool, +} + +impl Manifest { + pub fn load(json: &str) -> Self { + serde_json::from_str(json).expect("entity_manifest.json should be valid JSON") + } +} + +fn get<'a>(registry: &'a TypeRegistry, name: &str) -> Option<&'a TypeInfo> { + registry.types.get(&TypeId::new(name)) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Type classification +// ════════════════════════════════════════════════════════════════════════════ + +const BUILTIN: &[&str] = &[ + "f64", "f32", "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64", "bool", "String", "str", +]; +/// Types with their own hand-written conversion helpers in `ocs_module.rs` +/// (`vector3_to_py`/`py_to_vector3`, etc.) rather than generated ones. +const SPECIAL: &[&str] = &["Vector3", "Vector2", "Handle"]; + +enum Classify { + /// A C-like enum: every variant is a unit variant. Represented in Python + /// as its variant name (a plain string). + UnitEnum, + /// An enum whose variants carry one payload. Represented as + /// `{ "kind": "Variant", "value": ... }` so nested geometry such as + /// hatch boundary edges remains explicit and round-trippable. + TaggedEnum, + /// A struct whose only field is `bits` (an acadrust bitflags newtype, + /// e.g. `PolylineFlags`/`VertexFlags`). Represented in Python as a plain + /// integer via `.bits()`/`::from_bits(..)` — the named boolean accessors + /// some of these types have (`is_closed`/`set_closed`, ...) are exposed + /// per-field instead, via a manifest override, not generically. + BitFlags, + /// An ordinary struct: represented in Python as a nested dict. + Struct, +} + +fn classify<'a>(name: &str, info: &'a TypeInfo) -> Classify { + match info.kind { + TypeKind::Struct => { + if info.fields.len() == 1 && info.fields[0].name == "bits" { + Classify::BitFlags + } else { + Classify::Struct + } + } + TypeKind::Enum => { + if info.variants.iter().any(|v| !v.fields.is_empty()) { + if info.variants.iter().all(|v| v.fields.len() == 1) { + Classify::TaggedEnum + } else { + panic!("entity_manifest.json: {name} has a tagged variant without exactly one payload"); + } + } else { + Classify::UnitEnum + } + } + other => panic!("entity_manifest.json: {name} has unsupported registry kind {other:?}"), + } +} + +fn bits_field_type(info: &TypeInfo) -> &str { + info.fields[0].type_id.as_str() +} + +// ════════════════════════════════════════════════════════════════════════════ +// Helper-type closure (topologically ordered so each generated fn only calls +// ones already emitted above it) +// ════════════════════════════════════════════════════════════════════════════ + +fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec { + let mut needed: BTreeSet = BTreeSet::new(); + let mut queue: Vec = Vec::new(); + + for kind in &manifest.type_filter { + let Some(info) = get(registry, kind) else { + panic!("entity_manifest.json: unknown entity kind {kind}"); + }; + let override_def = manifest.overrides.get(kind); + for f in &info.fields { + if f.name == "common" { + continue; + } + if override_def + .and_then(|o| o.fields.get(&f.name)) + .map(|o| o.exclude || o.rust_getter.is_some()) + .unwrap_or(false) + { + // Excluded, or fully hand-supplied by the override — its + // type (if any) never needs a generated conversion pair. + continue; + } + queue.push(f.type_id.as_str().to_string()); + } + } + + while let Some(name) = queue.pop() { + if BUILTIN.contains(&name.as_str()) || SPECIAL.contains(&name.as_str()) { + continue; + } + if !needed.insert(name.clone()) { + continue; + } + let Some(info) = get(registry, &name) else { + panic!("entity_manifest.json: type {name} (reached transitively) is not in the type registry"); + }; + for f in &info.fields { + queue.push(f.type_id.as_str().to_string()); + } + for v in &info.variants { + for f in &v.fields { + queue.push(f.type_id.as_str().to_string()); + } + } + } + + // Fixpoint topo sort: repeatedly emit any type whose remaining + // dependencies (among `needed`) are already ordered. + let mut ordered = Vec::new(); + let mut remaining: Vec = needed.into_iter().collect(); + while !remaining.is_empty() { + let mut progressed = false; + for name in remaining.clone() { + let info = get(registry, &name).unwrap(); + let deps_pending = info + .fields + .iter() + .chain(info.variants.iter().flat_map(|v| v.fields.iter())) + .any(|f| { + let t = f.type_id.as_str(); + t != name && remaining.iter().any(|r| r == t) + }); + if !deps_pending { + ordered.push(name.clone()); + remaining.retain(|n| n != &name); + progressed = true; + } + } + if !progressed { + // Shouldn't happen for our closed, cycle-free set of types, but + // don't hang the build if it ever does. + ordered.append(&mut remaining); + break; + } + } + ordered +} + +// ════════════════════════════════════════════════════════════════════════════ +// Leaf conversion expressions (single value, not sequence/optional wrapping) +// ════════════════════════════════════════════════════════════════════════════ + +fn snake_case(name: &str) -> String { + let mut out = String::new(); + for (i, ch) in name.chars().enumerate() { + if ch.is_uppercase() && i > 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } + out +} + +fn native_rust_type(type_id: &str) -> &'static str { + match type_id { + "f64" | "f32" => "f64", + "i8" => "i8", + "i16" => "i16", + "i32" => "i32", + "i64" => "i64", + "u8" => "u8", + "u16" => "u16", + "u32" => "u32", + "u64" => "u64", + "bool" => "bool", + "String" | "str" => "String", + other => panic!("not a native type: {other}"), + } +} + +fn is_native(type_id: &str) -> bool { + BUILTIN.contains(&type_id) +} + +fn default_expr(type_id: &str) -> String { + match type_id { + "f64" | "f32" => "0.0".to_string(), + "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => "0".to_string(), + "bool" => "false".to_string(), + "String" | "str" => "String::new()".to_string(), + "Vector3" => "acadrust::types::Vector3::ZERO".to_string(), + "Vector2" => "acadrust::types::Vector2::ZERO".to_string(), + "Handle" => "acadrust::Handle::new(0)".to_string(), + other => format!("default_{}()", snake_case(other)), + } +} + +/// `item_expr` must already be a `&T` expression (typically a bare place like +/// `&value.field`, sometimes already parenthesized). Result: a +/// `PyResult` expression. +fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String { + // A plain function argument (`f(item_expr)`) never needs parens; a method + // call or cast directly on `item_expr` (`item_expr.method()`, + // `item_expr as T`) does, since `&` binds looser than both. + let parenthesized = format!("({item_expr})"); + match type_id { + "f64" | "f32" | "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "bool" => { + format!("Ok(vm.new_pyobj(*{parenthesized}))") + } + "String" | "str" => format!("Ok(vm.new_pyobj({parenthesized}.clone()))"), + "Vector3" => format!("vector3_to_py_dict(vm, {item_expr})"), + "Vector2" => format!("vector2_to_py(vm, {item_expr})"), + "Handle" => format!("Ok(vm.new_pyobj({parenthesized}.value()))"), + other => { + let info = get(registry, other) + .unwrap_or_else(|| panic!("type {other} missing from registry")); + match classify(other, info) { + Classify::UnitEnum => { + format!("Ok(vm.new_pyobj({}_name({item_expr}).to_owned()))", snake_case(other)) + } + Classify::TaggedEnum => format!("{}_to_dict(vm, {item_expr})", snake_case(other)), + Classify::BitFlags => format!("Ok(vm.new_pyobj({parenthesized}.bits() as i64))"), + Classify::Struct => format!("{}_to_dict(vm, {item_expr})", snake_case(other)), + } + } + } +} + +/// `value_expr` must already be an owned `PyObjectRef` expression. Result: a `PyResult` expression. +fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> String { + if is_native(type_id) { + return format!("{value_expr}.try_into_value::<{}>(vm)", native_rust_type(type_id)); + } + match type_id { + "Vector3" => format!("py_to_vector3_dict({value_expr}, vm)"), + "Vector2" => format!("py_to_vector2({value_expr}, vm)"), + "Handle" => format!("{value_expr}.try_into_value::(vm).map(acadrust::Handle::new)"), + other => { + let info = get(registry, other) + .unwrap_or_else(|| panic!("type {other} missing from registry")); + match classify(other, info) { + Classify::UnitEnum => format!( + "{value_expr}.try_into_value::(vm).and_then(|s| str_to_{}(s, vm))", + snake_case(other) + ), + Classify::TaggedEnum => format!("dict_to_{}({value_expr}, vm)", snake_case(other)), + Classify::BitFlags => { + let path = format!("acadrust::entities::{other}"); + let bits_ty = bits_field_type(info); + format!( + "{value_expr}.try_into_value::(vm).and_then(|n| <{bits_ty}>::try_from(n).map({path}::from_bits).map_err(|_| vm.new_value_error(\"{other} bits out of range\".to_owned())))" + ) + } + Classify::Struct => format!("dict_to_{}({value_expr}, vm)", snake_case(other)), + } + } + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Field-level (sequence/optional-aware) getter/setter expressions +// ════════════════════════════════════════════════════════════════════════════ + +/// `field_place` is a place expression for the field itself (e.g. `"value.radius"`), +/// reached through an already-borrowed parent. Result: a `PyResult` expression. +fn getter_for_field( + registry: &TypeRegistry, + type_id: &str, + optional: bool, + is_sequence: bool, + field_place: &str, +) -> String { + if is_sequence { + let inner = leaf_to_py(registry, type_id, "item"); + format!( + "{{ let items = {field_place}.iter().map(|item| {inner}).collect::>>()?; Ok(vm.ctx.new_list(items).into()) }}" + ) + } else if optional { + let inner = leaf_to_py(registry, type_id, "v"); + format!("match &{field_place} {{ None => Ok(vm.ctx.none()), Some(v) => {inner} }}") + } else { + leaf_to_py(registry, type_id, &format!("&{field_place}")) + } +} + +/// `py_name` is the dict key to read; `keep_expr` is the expression to use +/// when the key is absent (or explicitly `None`, for non-`Option` fields) — +/// the fresh constructor's value for `ocs.add()`, the field's current value +/// (`{var}.{field}.clone()`) for `ocs.update()`, or a diverging `return Err(..)` block for +/// a required-but-missing `ocs.add()` field. Result: a plain (non-`PyResult`) +/// expression of the field's own Rust type (`T`, `Vec`, or `Option`). +fn setter_for_field( + registry: &TypeRegistry, + type_id: &str, + optional: bool, + is_sequence: bool, + py_name: &str, + keep_expr: &str, +) -> String { + if is_sequence { + let inner = leaf_from_py(registry, type_id, "item"); + format!( + "match dict.get_item_opt(\"{py_name}\", vm)? {{ None => {keep_expr}, Some(v) => {{ let items: Vec = v.try_into_value(vm)?; items.into_iter().map(|item| {inner}).collect::>>()? }} }}" + ) + } else if optional { + let inner = leaf_from_py(registry, type_id, "v"); + format!( + "match dict.get_item_opt(\"{py_name}\", vm)? {{ None => {keep_expr}, Some(v) if vm.is_none(&v) => None, Some(v) => Some({inner}?) }}" + ) + } else { + let inner = leaf_from_py(registry, type_id, "v"); + format!( + "match dict.get_item_opt(\"{py_name}\", vm)? {{ None => {keep_expr}, Some(v) if vm.is_none(&v) => {keep_expr}, Some(v) => {inner}? }}" + ) + } +} + +fn field_default_expr(f: &FieldInfo) -> String { + if f.is_sequence { + "Vec::new()".to_string() + } else if f.optional { + "None".to_string() + } else { + default_expr(f.type_id.as_str()) + } +} + +/// A diverging block expression: aborts `dict_to_entity` with a clear error +/// instead of producing a value. Valid anywhere an expression of any type is +/// expected (Rust's `!` coerces to everything), so it drops straight into a +/// `match` arm exactly like a real value would. +fn required_error_expr(entity_kind: &str, py_name: &str) -> String { + format!( + "{{ return Err(vm.new_value_error(\"ocs.add: {entity_kind} requires \\\"{py_name}\\\"\".to_owned())); }}" + ) +} + +/// Wraps an already-built sequence-field setter expression with a +/// non-empty check, for a required sequence field (e.g. a polyline's +/// `vertices`) — covers both "key missing" and "key present but `[]`" in +/// one place, since both parse to the same empty `Vec`. +fn wrap_required_nonempty(setter_expr: &str, entity_kind: &str, py_name: &str) -> String { + format!( + "{{ let parsed = {setter_expr}; if parsed.is_empty() {{ return Err(vm.new_value_error(\"ocs.add: {entity_kind} requires at least one \\\"{py_name}\\\"\".to_owned())); }} parsed }}" + ) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Per-helper-type generation +// ════════════════════════════════════════════════════════════════════════════ + +fn gen_unit_enum(name: &str, info: &TypeInfo) -> String { + let path = format!("acadrust::entities::{name}"); + let snake = snake_case(name); + let mut name_arms = String::new(); + let mut parse_arms = String::new(); + for v in &info.variants { + name_arms.push_str(&format!(" {path}::{v} => \"{v}\",\n", v = v.name)); + parse_arms.push_str(&format!(" \"{v}\" => Ok({path}::{v}),\n", v = v.name)); + } + let first_variant = info + .variants + .first() + .unwrap_or_else(|| panic!("{name}: unit enum has no variants")) + .name + .clone(); + format!( + r#"fn {snake}_name(value: &{path}) -> &'static str {{ + match value {{ +{name_arms} }} +}} + +fn str_to_{snake}(value: String, vm: &VirtualMachine) -> PyResult<{path}> {{ + match value.as_str() {{ +{parse_arms} other => Err(vm.new_value_error(format!("ocs: unsupported {name} value: {{other}}"))), + }} +}} + +#[allow(dead_code)] +fn default_{snake}() -> {path} {{ + {path}::{first_variant} +}} + +"# + ) +} + +fn gen_bitflags(name: &str) -> String { + let path = format!("acadrust::entities::{name}"); + let snake = snake_case(name); + format!( + r#"#[allow(dead_code)] +fn default_{snake}() -> {path} {{ + {path}::new() +}} + +"# + ) +} + +fn gen_tagged_enum(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String { + let path = format!("acadrust::entities::{name}"); + let snake = snake_case(name); + let mut to_arms = String::new(); + let mut from_arms = String::new(); + for variant in &info.variants { + let payload = &variant.fields[0]; + let getter = leaf_to_py(registry, payload.type_id.as_str(), "value"); + let setter = leaf_from_py(registry, payload.type_id.as_str(), "payload"); + to_arms.push_str(&format!( + " {path}::{variant}(value) => {{ let dict = vm.ctx.new_dict(); dict.set_item(\"kind\", vm.new_pyobj(\"{variant}\"), vm)?; dict.set_item(\"value\", {getter}?, vm)?; Ok(dict.into()) }}\n", + variant = variant.name, + )); + from_arms.push_str(&format!( + " \"{variant}\" => {{ let payload = dict.get_item(\"value\", vm)?; Ok({path}::{variant}({setter}?)) }}\n", + variant = variant.name, + )); + } + let first = &info.variants[0]; + let default_payload = default_expr(first.fields[0].type_id.as_str()); + format!( + r#"fn {snake}_to_dict(vm: &VirtualMachine, value: &{path}) -> PyResult {{ + match value {{ +{to_arms} }} +}} + +fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}> {{ + let dict = value.try_into_value::(vm)?; + let kind = dict.get_item("kind", vm)?.try_into_value::(vm)?; + match kind.as_str() {{ +{from_arms} other => Err(vm.new_value_error(format!("ocs: unsupported {name} kind: {{other}}"))), + }} +}} + +#[allow(dead_code)] +fn default_{snake}() -> {path} {{ + {path}::{first_variant}({default_payload}) +}} + +"#, + first_variant = first.name, + ) +} + +fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String { + let path = format!("acadrust::entities::{name}"); + let snake = snake_case(name); + let mut to_dict_body = String::new(); + let mut from_dict_fields = String::new(); + let mut default_fields = String::new(); + for f in &info.fields { + let getter = getter_for_field( + registry, + f.type_id.as_str(), + f.optional, + f.is_sequence, + &format!("value.{}", f.name), + ); + to_dict_body.push_str(&format!( + " dict.set_item(\"{fname}\", {getter}?, vm)?;\n", + fname = f.name + )); + let setter = setter_for_field( + registry, + f.type_id.as_str(), + f.optional, + f.is_sequence, + &f.name, + &field_default_expr(f), + ); + from_dict_fields.push_str(&format!(" {fname}: {setter},\n", fname = f.name)); + default_fields.push_str(&format!( + " {fname}: {def},\n", + fname = f.name, + def = field_default_expr(f) + )); + } + format!( + r#"fn {snake}_to_dict(vm: &VirtualMachine, value: &{path}) -> PyResult {{ + let dict = vm.ctx.new_dict(); +{to_dict_body} Ok(dict.into()) +}} + +fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}> {{ + let dict = value.try_into_value::(vm)?; + Ok({path} {{ +{from_dict_fields} }}) +}} + +#[allow(dead_code)] +fn default_{snake}() -> {path} {{ + {path} {{ +{default_fields} }} +}} + +"# + ) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Top-level entity fields (registry + manifest overrides) +// ════════════════════════════════════════════════════════════════════════════ + +struct ResolvedEntityField { + py_name: String, + /// A `PyResult` expression. + getter: String, + /// A full, semicolon-terminated Rust statement. + setter: String, +} + +/// `ocs.add()` builds an entity from scratch (missing fields fall back to a +/// type default, or error if `required`); `ocs.update()` merges onto an +/// already-`.clone()`d existing entity (missing fields simply keep whatever +/// `var` already holds — see [[project_opencad_python_generic_entity_crud_plan]] +/// in this session's memory for why: reconstructing from a partial dict on +/// update could silently reset properties this generator doesn't represent +/// at all, e.g. `EntityCommon`'s color/line-weight/XDATA). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + Add, + Update, +} + +fn resolve_entity_fields( + manifest: &Manifest, + registry: &TypeRegistry, + kind: &str, + info: &TypeInfo, + var: &str, + mode: Mode, +) -> Vec { + let override_def = manifest.overrides.get(kind).cloned().unwrap_or_default(); + let mut out = Vec::new(); + for f in &info.fields { + if f.name == "common" { + continue; + } + let field_override = override_def.fields.get(&f.name).cloned(); + if let Some(ov) = &field_override { + if ov.exclude { + continue; + } + if let (Some(getter_expr), Some(setter_stmt)) = (&ov.rust_getter, &ov.rust_setter) { + assert!(!ov.read_only, "{kind}.{} cannot be read_only with a rust_setter", f.name); + assert!( + !ov.required, + "entity_manifest.json: {kind}.{}: \"required\" only applies to the generic \ + field path, not a rust_getter/rust_setter override", + f.name + ); + let py_name = ov.python_name.clone().unwrap_or_else(|| f.name.clone()); + let getter = format!("Ok(vm.new_pyobj({}))", getter_expr.replace("VAR", var)); + let setter = setter_stmt.replace("VAR", var); + out.push(ResolvedEntityField { py_name, getter, setter }); + continue; + } + } + let py_name = field_override + .as_ref() + .and_then(|o| o.python_name.clone()) + .unwrap_or_else(|| f.name.clone()); + let required = field_override.as_ref().map(|o| o.required).unwrap_or(false); + let getter = getter_for_field( + registry, + f.type_id.as_str(), + f.optional, + f.is_sequence, + &format!("{var}.{}", f.name), + ); + if field_override.as_ref().is_some_and(|ov| ov.read_only) { + let setter = format!("if dict.get_item_opt(\"{py_name}\", vm)?.is_some() {{ return Err(vm.new_value_error(\"{kind}.{py_name} is read-only\".to_owned())); }}"); + out.push(ResolvedEntityField { py_name, getter, setter }); + continue; + } + let keep_expr = match mode { + Mode::Update => format!("{var}.{}.clone()", f.name), + Mode::Add if required && !f.is_sequence => required_error_expr(kind, &py_name), + Mode::Add => format!("{var}.{}.clone()", f.name), + }; + let mut setter_expr = + setter_for_field(registry, f.type_id.as_str(), f.optional, f.is_sequence, &py_name, &keep_expr); + if mode == Mode::Add && required && f.is_sequence { + setter_expr = wrap_required_nonempty(&setter_expr, kind, &py_name); + } + let setter = format!("{var}.{} = {setter_expr};", f.name); + out.push(ResolvedEntityField { py_name, getter, setter }); + } + out +} + +fn gen_entity_to_dict(manifest: &Manifest, registry: &TypeRegistry) -> String { + let mut arms = String::new(); + for kind in &manifest.type_filter { + let info = get(registry, kind).unwrap(); + let var = "value"; + let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add); + let mut body = String::new(); + body.push_str(" let dict = vm.ctx.new_dict();\n"); + body.push_str(&format!(" dict.set_item(\"kind\", vm.new_pyobj(\"{kind}\"), vm)?;\n")); + body.push_str(&format!( + " dict.set_item(\"handle\", vm.new_pyobj({var}.common.handle.value()), vm)?;\n" + )); + body.push_str(&format!( + " dict.set_item(\"layer\", vm.new_pyobj({var}.common.layer.clone()), vm)?;\n" + )); + if manifest.base_fields.iter().any(|field| field == "owner_handle") { + body.push_str(&format!( + " dict.set_item(\"owner_handle\", vm.new_pyobj({var}.common.owner_handle.value()), vm)?;\n" + )); + } + for f in &fields { + body.push_str(&format!( + " dict.set_item(\"{}\", {}?, vm)?;\n", + f.py_name, f.getter + )); + } + body.push_str(" Ok(dict.into())\n"); + arms.push_str(&format!( + " acadrust::EntityType::{kind}({var}) => {{\n{body} }}\n" + )); + } + format!( + r#"/// Convert any generic-CRUD-supported entity into a plain dict with a +/// `"kind"` key (matching the convention every other `ocs.*` dict already +/// uses, e.g. XDATA's `{{"kind": ..., "value": ...}}`), plus `"handle"` and +/// `"layer"` and every entity-specific field the type registry knows about. +pub(crate) fn entity_to_dict(vm: &VirtualMachine, entity: &acadrust::EntityType) -> PyResult {{ + match entity {{ +{arms} _ => Err(vm.new_value_error( + "ocs: this entity kind is not supported by the generic add/update API yet".to_owned(), + )), + }} +}} + +"# + ) +} + +fn gen_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { + let mut arms = String::new(); + for kind in &manifest.type_filter { + if manifest.overrides.get(kind).is_some_and(|override_def| override_def.update_only) { + continue; + } + let info = get(registry, kind).unwrap(); + let var = "value"; + let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add); + let mut body = String::new(); + let mut allowed = vec!["kind", "handle", "layer"]; + if manifest.base_fields.iter().any(|field| field == "owner_handle") { + allowed.push("owner_handle"); + } + allowed.extend(fields.iter().map(|field| field.py_name.as_str())); + body.push_str(&format!( + " ensure_known_entity_keys(dict, \"{kind}\", &{:?}, vm)?;\n", + allowed + )); + let constructor = manifest.overrides.get(kind) + .and_then(|override_def| override_def.rust_constructor.as_deref()) + .map(str::to_owned) + .unwrap_or_else(|| format!("acadrust::entities::{kind}::default()")); + body.push_str(&format!(" let mut {var} = {constructor};\n")); + body.push_str(&format!( + " {var}.common.handle = acadrust::Handle::new(get_opt_u64(dict, \"handle\", vm)?);\n" + )); + body.push_str(&format!( + " {var}.common.layer = get_opt_string(dict, \"layer\", \"0\", vm)?;\n" + )); + if manifest.base_fields.iter().any(|field| field == "owner_handle") { + body.push_str(&format!( + " {var}.common.owner_handle = acadrust::Handle::new(get_opt_u64(dict, \"owner_handle\", vm)?);\n" + )); + } + for f in &fields { + body.push_str(&format!(" {}\n", f.setter)); + } + body.push_str(&format!(" acadrust::EntityType::{kind}({var})\n")); + arms.push_str(&format!(" \"{kind}\" => {{\n{body} }}\n")); + } + format!( + r#"/// Build a brand-new entity from a dict with a `"kind"` key (see +/// `entity_to_dict`) — the `ocs.add()` direction. Fields the manifest marks +/// `"required"` (the geometry that actually defines the shape — a line's +/// endpoints, a circle's center/radius, ...) error if missing rather than +/// silently defaulting to zero; every other field defaults sensibly (what a +/// fresh `acadrust::entities::X::default()` would already hold). +pub(crate) fn dict_to_entity(dict: &rustpython_vm::builtins::PyDictRef, vm: &VirtualMachine) -> PyResult {{ + let kind = dict.get_item("kind", vm)?.try_into_value::(vm)?; + Ok(match kind.as_str() {{ +{arms} other => {{ + return Err(vm.new_value_error(format!("ocs: unsupported entity kind: {{other}}"))); + }} + }}) +}} + +"# + ) +} + +fn gen_apply_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { + let mut arms = String::new(); + for kind in &manifest.type_filter { + let info = get(registry, kind).unwrap(); + let var = "value"; + let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Update); + let mut body = String::new(); + let mut allowed = vec!["kind", "handle", "layer"]; + if manifest.base_fields.iter().any(|field| field == "owner_handle") { + allowed.push("owner_handle"); + } + allowed.extend(fields.iter().map(|field| field.py_name.as_str())); + body.push_str(&format!( + " ensure_known_entity_keys(dict, \"{kind}\", &{:?}, vm)?;\n", + allowed + )); + body.push_str(&format!(" let mut {var} = existing_value.clone();\n")); + // `handle` is never touched — an update can't rehome an entity's + // identity. `layer` (unlike every other field) is promoted onto + // every dict by `entity_to_dict`/read by `dict_to_entity` without a + // manifest entry, so it's handled by hand here too, the same way + // `dict_to_entity` hand-writes it above — merge semantics: keep the + // existing layer unless the dict actually specifies one. + body.push_str(&format!( + " {var}.common.layer = match dict.get_item_opt(\"layer\", vm)? {{ \ + None => {var}.common.layer.clone(), \ + Some(v) if vm.is_none(&v) => {var}.common.layer.clone(), \ + Some(v) => v.try_into_value::(vm)? }};\n" + )); + if manifest.base_fields.iter().any(|field| field == "owner_handle") { + body.push_str(&format!( + " if let Some(owner) = dict.get_item_opt(\"owner_handle\", vm)? {{ \ + let owner = owner.try_into_value::(vm)?; \ + if owner != {var}.common.owner_handle.value() {{ \ + return Err(vm.new_value_error(\"owner_handle is read-only after creation\".to_owned())); }} }};\n" + )); + } + for f in &fields { + body.push_str(&format!(" {}\n", f.setter)); + } + body.push_str(&format!(" acadrust::EntityType::{kind}({var})\n")); + arms.push_str(&format!( + " acadrust::EntityType::{kind}(existing_value) => {{\n{body} }}\n" + )); + } + format!( + r#"/// Apply a dict's keys onto a *clone* of `existing` — the `ocs.update()` +/// direction. Unlike `dict_to_entity`, a key missing from `dict` means +/// "leave this field as it already was", not "reset it to a type default": +/// reconstructing a fresh entity from a necessarily-partial dict would +/// silently drop whatever this generator can't represent at all (most of +/// `EntityCommon` — color, line weight, XDATA, ...) the moment a script +/// updates just one field. `existing`'s own kind is authoritative; a `dict` +/// `"kind"` (if present at all — round-tripped from a prior `get_entity()`) +/// is never consulted, since an update can't change an entity's shape. +pub(crate) fn apply_dict_to_entity( + existing: &acadrust::EntityType, + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, +) -> PyResult {{ + Ok(match existing {{ +{arms} _ => {{ + return Err(vm.new_value_error( + "ocs: this entity kind is not supported by the generic add/update API yet" + .to_owned(), + )); + }} + }}) +}} +"# + ) +} + +// ════════════════════════════════════════════════════════════════════════════ +// Entry point +// ════════════════════════════════════════════════════════════════════════════ + +pub fn generate_entity_crud(manifest: &Manifest, registry: &TypeRegistry) -> String { + let mut out = String::new(); + out.push_str( + "// @generated by opencad-python/build.rs from entity_manifest.json + the\n\ + // ocs_plugin_api embedded type registry. Do not edit by hand — edit\n\ + // entity_manifest.json or build/generate.rs instead.\n\n", + ); + + for name in helper_closure(manifest, registry) { + let info = get(registry, &name).unwrap(); + match classify(&name, info) { + Classify::UnitEnum => out.push_str(&gen_unit_enum(&name, info)), + Classify::TaggedEnum => out.push_str(&gen_tagged_enum(&name, info, registry)), + Classify::BitFlags => out.push_str(&gen_bitflags(&name)), + Classify::Struct => out.push_str(&gen_struct(&name, info, registry)), + } + } + + out.push_str(&gen_entity_to_dict(manifest, registry)); + out.push_str(&gen_dict_to_entity(manifest, registry)); + out.push_str(&gen_apply_dict_to_entity(manifest, registry)); + out +} diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json new file mode 100644 index 000000000..43b71be06 --- /dev/null +++ b/plugins/opencad-python/entity_manifest.json @@ -0,0 +1,233 @@ +{ + "type_filter": [ + "Point", + "Line", + "Circle", + "Arc", + "Ellipse", + "Polyline", + "Polyline2D", + "Polyline3D", + "LwPolyline", + "Spline", + "Text", + "MText", + "Ray", + "XLine", + "Solid", + "Face3D", + "Insert", + "Tolerance", + "Shape", + "AttributeDefinition", + "AttributeEntity", + "Hatch" + ], + "base_fields": ["handle", "layer", "owner_handle"], + "overrides": { + "Point": { + "fields": { + "location": { "required": true } + } + }, + "Line": { + "fields": { + "start": { "required": true }, + "end": { "required": true } + } + }, + "Circle": { + "fields": { + "center": { "required": true }, + "radius": { "required": true } + } + }, + "Arc": { + "fields": { + "center": { "required": true }, + "radius": { "required": true }, + "start_angle": { "required": true }, + "end_angle": { "required": true } + } + }, + "Ellipse": { + "fields": { + "center": { "required": true }, + "major_axis": { "required": true }, + "minor_axis_ratio": { "required": true } + } + }, + "Polyline": { + "fields": { + "vertices": { "required": true }, + "flags": { + "python_name": "closed", + "rust_getter": "VAR.flags.is_closed()", + "rust_setter": "VAR.flags.set_closed(get_opt_bool(dict, \"closed\", vm)?);" + } + } + }, + "Polyline2D": { + "fields": { + "vertices": { "required": true }, + "flags": { + "python_name": "closed", + "rust_getter": "VAR.flags.is_closed()", + "rust_setter": "VAR.flags.set_closed(get_opt_bool(dict, \"closed\", vm)?);" + } + } + }, + "Polyline3D": { + "fields": { + "vertices": { "required": true }, + "smooth_type": { + "exclude": true, + "_comment": "acadrust defines two distinct SmoothSurfaceType enums with the same name (polyline.rs, used by Polyline2D's smooth_surface — and a separate one private to polyline3d.rs, used here); the type registry's tracer keys purely by name and collapses them, so the generically generated conversion resolves to the wrong one. Needs a hand-written override with the fully qualified acadrust::entities::polyline3d::SmoothSurfaceType path to support, not just a rename." + } + } + }, + "LwPolyline": { + "fields": { + "vertices": { "required": true } + } + }, + "Spline": { + "fields": { + "control_points": { "required": true } + } + }, + "Text": { + "fields": { + "value": { "python_name": "text", "required": true }, + "insertion_point": { "python_name": "insertion", "required": true } + } + }, + "MText": { + "fields": { + "value": { "python_name": "text", "required": true }, + "insertion_point": { "python_name": "insertion", "required": true }, + "background_color": { + "exclude": true, + "_comment": "Color is a tagged enum (Index(u8)/Rgb{r,g,b} carry data, not just unit variants) — this generator only handles C-like unit enums generically; EntityCommon's own color/line_weight/transparency fields are out of scope for the same reason (see base_fields)." + } + } + }, + "Ray": { + "fields": { + "base_point": { "required": true }, + "direction": { "required": true } + } + }, + "XLine": { + "fields": { + "base_point": { "required": true }, + "direction": { "required": true } + } + }, + "Solid": { + "rust_constructor": "acadrust::entities::Solid::new(acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO)", + "fields": { + "first_corner": { "required": true }, + "second_corner": { "required": true }, + "third_corner": { "required": true }, + "fourth_corner": { "required": true } + } + }, + "Face3D": { + "rust_constructor": "acadrust::entities::Face3D::new(acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO)", + "fields": { + "first_corner": { "required": true }, + "second_corner": { "required": true }, + "third_corner": { "required": true }, + "fourth_corner": { "required": true } + } + }, + "Insert": { + "update_only": true, + "fields": { + "block_name": { "read_only": true }, + "x_scale": { + "rust_getter": "VAR.x_scale()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"x_scale\", vm)? { let scale = v.try_into_value::(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.x_scale must be finite and nonzero\".to_owned())); } VAR.set_x_scale(scale); }" + }, + "y_scale": { + "rust_getter": "VAR.y_scale()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"y_scale\", vm)? { let scale = v.try_into_value::(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.y_scale must be finite and nonzero\".to_owned())); } VAR.set_y_scale(scale); }" + }, + "z_scale": { + "rust_getter": "VAR.z_scale()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"z_scale\", vm)? { let scale = v.try_into_value::(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.z_scale must be finite and nonzero\".to_owned())); } VAR.set_z_scale(scale); }" + }, + "dwg_minsert": { "exclude": true }, + "attributes": { "exclude": true }, + "view_rep_handle": { "exclude": true }, + "seqend_handle": { "exclude": true } + } + }, + "Tolerance": { + "fields": { + "insertion_point": { "required": true }, + "text": { "required": true }, + "dimension_style_name": { + "rust_getter": "VAR.dimension_style_name.clone()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"dimension_style_name\", vm)? { VAR.dimension_style_name = v.try_into_value::(vm)?; VAR.dimension_style_handle = None; }" + }, + "dimension_style_handle": { "read_only": true }, + "dwg_unknown_short": { "exclude": true } + } + }, + "Shape": { + "fields": { + "insertion_point": { "required": true }, + "size": { "required": true }, + "shape_name": { "required": true }, + "shape_number": { "required": true }, + "style_name": { + "rust_getter": "VAR.style_name.clone()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"style_name\", vm)? { VAR.style_name = v.try_into_value::(vm)?; VAR.style_handle = None; }" + }, + "style_handle": { "read_only": true } + } + }, + "AttributeDefinition": { + "fields": { + "tag": { "required": true }, + "insertion_point": { "required": true }, + "height": { "required": true }, + "embedded_mtext": { + "exclude": true, + "_comment": "Embedded R2018+ MTEXT carries linked layout state. It remains snapshot-readable but is preserved untouched by Python edits until linked-payload mutation has dedicated validation." + } + } + }, + "AttributeEntity": { + "fields": { + "tag": { "required": true }, + "value": { "required": true }, + "insertion_point": { "required": true }, + "height": { "required": true }, + "attdef_handle": { "read_only": true }, + "embedded_mtext": { + "exclude": true, + "_comment": "The embedded R2018+ MTEXT payload remains snapshot-readable and is preserved by partial edits." + } + } + }, + "Hatch": { + "fields": { + "paths": { "required": true }, + "is_mpolygon": { + "exclude": true, + "_comment": "MPOLYGON is a distinct class-backed record and is preserved without exposing Hatch creation semantics." + }, + "gradient_color": { + "exclude": true, + "_comment": "Gradient payload mutation requires a separate validated model; partial Hatch edits preserve it." + }, + "mpolygon_hatch_color": { "exclude": true }, + "mpolygon_x_direction": { "exclude": true }, + "mpolygon_boundary_handle_count": { "exclude": true } + } + } + } +} diff --git a/plugins/opencad-python/examples/create_layout_fixture.rs b/plugins/opencad-python/examples/create_layout_fixture.rs new file mode 100644 index 000000000..26e61e341 --- /dev/null +++ b/plugins/opencad-python/examples/create_layout_fixture.rs @@ -0,0 +1,54 @@ +//! Create one disposable DWG with a paper layout and rectangular viewport. +//! Usage: cargo run --example create_layout_fixture -- /absolute/output.dwg + +use std::io::Write; + +use ocs_plugin_api::host::acadrust::{ + entities::{Line, Text, Viewport}, + CadDocument, DwgWriter, EntityType, Vector3, +}; + +fn main() -> Result<(), Box> { + let output = std::env::args().nth(1).ok_or("expected output DWG path")?; + let output = std::path::PathBuf::from(output); + let twisted = std::env::args().nth(2).as_deref() == Some("--twisted"); + if output.extension().is_none_or(|value| value != "dwg") + || output.parent().is_none_or(|parent| !parent.is_dir()) + { + return Err("output must be a .dwg in an existing directory".into()); + } + let mut document = CadDocument::new(); + document.add_layout("Sheet")?; + document.add_entity(EntityType::Line(Line::from_coords( + -50.0, 0.0, 0.0, 50.0, 0.0, 0.0, + )))?; + document.add_entity_to_layout( + EntityType::Text(Text::with_value( + "Sheet note", + Vector3::new(20.0, 20.0, 0.0), + )), + "Sheet", + )?; + let mut viewport = Viewport::new(); + viewport.id = 2; + viewport.center = Vector3::new(100.0, 80.0, 0.0); + viewport.width = 100.0; + viewport.height = 50.0; + viewport.view_height = 100.0; + viewport.view_center = Vector3::new(10.0, 20.0, 0.0); + viewport.view_target = Vector3::ZERO; + if twisted { + viewport.twist_angle = std::f64::consts::FRAC_PI_2; + viewport.view_target = Vector3::new(5.0, 10.0, 0.0); + } + document.add_entity_to_layout(EntityType::Viewport(viewport), "Sheet")?; + + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&output)?; + DwgWriter::write_to_writer(&mut file, &document)?; + file.flush()?; + println!("Created {}", output.display()); + Ok(()) +} diff --git a/plugins/opencad-python/examples/example.py b/plugins/opencad-python/examples/example.py new file mode 100644 index 000000000..464ffc8f9 --- /dev/null +++ b/plugins/opencad-python/examples/example.py @@ -0,0 +1,90 @@ +# Example script for opencad-python's PY_RUN command. +# +# Historical experimental command-replay example. The portable default +# build omits ocs.command()/ocs.select(); nested replay may hang in OCS 2026.37. +# +# Draws two rectangles, a triangle, an arc, a pair of lines made parallel by +# a real PCONSTRAINT, a circle made tangent to a line by a real TCONSTRAINT, +# and a row of points added in one ocs.add_points() batch call, one of which +# gets an XDATA record written, read back, and removed. Every ocs.add_*/ +# ocs.write_record call in one PY_RUN shares a script undo group. Command +# replay has separate host undo semantics. +# +# These ARE real, persistent constraint objects, not just geometrically +# correct-looking shapes: drag one of the two "parallel" lines afterward and +# the other follows to stay parallel, same for the tangent circle. Verified +# end-to-end (not just "it compiles"): after ocs.command("PCONSTRAINT") / +# ("TCONSTRAINT"), the geometry visibly changes and satisfies the +# relationship exactly (checked numerically — direction-vector cross +# product ~0 for parallel, center-to-line distance == radius for tangent). +# +# ocs.select(handles) + ocs.command(name) is what makes this possible: +# constraint commands read a prior *selection* (a "Select objects:" +# prompt), not picks fed as command-line tokens the way LINE's points are — +# select the entities first, then run the bare command name. See PLUGIN.md +# for the two host-side additions (HostApi::run_command / set_selection) +# this needed and exactly what they do and don't cover. + +if not hasattr(ocs, "command") or not hasattr(ocs, "select"): + raise RuntimeError("This example requires experimental command replay host APIs") + +def rectangle(x, y, w, h): + l1 = ocs.add_line(x, y, x + w, y) + l2 = ocs.add_line(x + w, y, x + w, y + h) + l3 = ocs.add_line(x + w, y + h, x, y + h) + l4 = ocs.add_line(x, y + h, x, y) + return [l1, l2, l3, l4] + +# ── Two rectangles (not aligned to each other — see the note below) ─────── +rectangle(0, 0, 20, 10) +rectangle(30, 2, 18, 9) + +# ── A triangle (three lines closing a loop) ──────────────────────────────── +ocs.add_line(60, 0, 75, 0) +ocs.add_line(75, 0, 67.5, 12) +ocs.add_line(67.5, 12, 60, 0) + +# ── An arc, for variety ───────────────────────────────────────────────────── +ocs.add_arc(67.5, 20, 8, 0, 180) + +# ── Two lines, deliberately NOT parallel — then made parallel by a real ─── +# ── persistent PCONSTRAINT (not just recomputed coordinates). +line_a = ocs.add_line(0, 20, 20, 24) +line_b = ocs.add_line(0, 30, 20, 33) +ocs.select([line_a, line_b]) +ocs.command("PCONSTRAINT") + +# ── A line and a circle, deliberately NOT touching — then made tangent by ── +# ── a real persistent TCONSTRAINT. +tangent_line = ocs.add_line(0, 40, 20, 40) +tangent_circle = ocs.add_circle(10, 44, 3) +ocs.select([tangent_line, tangent_circle]) +ocs.command("TCONSTRAINT") + +# ── What's NOT in this script: aligning the two rectangles to each other ── +# ── (a real ALIGN, not just matching coordinates). Tried it separately: +# ── ocs.command("ALIGN ") DOES apply the transform correctly, +# ── but ALIGN asks a trailing "Scale objects? [Yes/No]" question that +# ── ocs.command()'s point-feeding doesn't answer (it sends exactly one +# ── trailing Enter, and ALIGN needs two — one to skip the optional 3rd +# ── point pair, one for the scale question) — it leaves that prompt open +# ── instead of cleanly finishing. Left out of this script rather than +# ── shipping something that half-works; see PLUGIN.md for the detail. + +# ── A batch of points, added in one HostApi::add_entities() call instead ── +# ── of one ocs.add_line-style call per point. +point_handles = ocs.add_points([[x, 50, 0] for x in range(0, 21, 2)]) + +# ── Tag one of those points with an XDATA record, read it back, then ─────── +# ── remove it — round-tripping every supported value kind. +tagged = point_handles[0] +ocs.write_record(tagged, "OPENCAD_EXAMPLE", [ + {"kind": "String", "value": "origin marker"}, + {"kind": "Point3D", "value": [0.0, 50.0, 0.0]}, + {"kind": "Integer32", "value": 1}, +]) +record = ocs.read_record(tagged, "OPENCAD_EXAMPLE") +assert record["app_name"] == "OPENCAD_EXAMPLE" +assert record["values"][0] == {"kind": "String", "value": "origin marker"} +ocs.remove_record(tagged, "OPENCAD_EXAMPLE") +assert ocs.read_record(tagged, "OPENCAD_EXAMPLE") is None diff --git a/plugins/opencad-python/plugin.toml b/plugins/opencad-python/plugin.toml new file mode 100644 index 000000000..ad4188a5e --- /dev/null +++ b/plugins/opencad-python/plugin.toml @@ -0,0 +1,22 @@ +[plugin] +id = "opencad.python" +name = "Python Scripting" +version = "0.1.0" +description = "Embedded Python scripting (RustPython) for Open CAD Studio." +author = "Felix Riestra" +license = "GPL-3.0-only" +repository = "felixriestra/OpenCADStudio" + +[opencad] +api_version = 7 +# Both filled by release.yml at build time from the actual compiler and +# Cargo.lock's resolved acadrust source. Declare acadrust_source even though +# it's optional: the host's compatibility check only runs at all if this +# field is present, so an omitted field is silently treated as compatible +# rather than rejected (see OpenCADStudio's plugin-architecture.md, +# "Build-time ABI metadata"). +rustc_version = "__RUSTC_VERSION__" +acadrust_source = "__ACADRUST_SOURCE__" +ribbon_order = 60 +command_prefixes = ["PY_"] +xdata_apps = [] diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py new file mode 100644 index 000000000..1e5d4281d --- /dev/null +++ b/plugins/opencad-python/src/document_model.py @@ -0,0 +1,133 @@ +"""Thin Python view over OCS host API v7 entity transactions and coverage.""" + +class _Entity: + def __init__(self, document, handle): + object.__setattr__(self, "_document", document) + object.__setattr__(self, "handle", handle) + + def _data(self): + data = ocs.entity_descriptor(self.handle) + if data is None: + raise KeyError("entity %s is absent or outside the Python schema" % self.handle) + return data + + def __getattr__(self, name): + data = self._data() + if name not in data: + raise AttributeError("property %s is not in the Python entity schema" % name) + pending = self._document._pending or {} + value = pending.get(self.handle, {}).get(name, data[name]) + if isinstance(value, dict) and all(axis in value for axis in ("x", "y")): + return tuple(value[axis] for axis in ("x", "y", "z") if axis in value) + return value + + @property + def coverage(self): + return ocs.entity_coverage(self._data()["kind"]) + + @property + def snapshot(self): + """Detached copy of all serialized CAD fields for this entity.""" + data = ocs.entity_snapshot(self.handle) + if data is None: + raise KeyError("entity %s is absent" % self.handle) + return data[self._data()["kind"]] + + def __setattr__(self, name, value): + if self._document._pending is None: + raise RuntimeError("entity writes require doc.transaction(...)") + data = self._data() + can_edit = name in self.coverage["editable"] + if not can_edit or name not in data or name in ("kind", "handle", "_editable"): + raise AttributeError("property %s is not editable through this schema" % name) + old = data[name] + if isinstance(old, dict) and all(axis in old for axis in ("x", "y")): + axes = tuple(axis for axis in ("x", "y", "z") if axis in old) + if not isinstance(value, (tuple, list)) or len(value) != len(axes): + raise ValueError("%s expects %s coordinates" % (name, len(axes))) + value = dict(zip(axes, value)) + self._document._pending.setdefault(self.handle, {})[name] = value + + +class _Entities: + def __init__(self, document): + self._document = document + + def __getitem__(self, handle): + entity = _Entity(self._document, int(handle)) + entity._data() + return entity + + def __iter__(self): + for handle in ocs.entity_handles(): + yield self[handle] + + def __len__(self): + return len(ocs.entity_handles()) + + +class _Transaction: + def __init__(self, document, label): + self.document, self.label = document, label + + def __enter__(self): + if self.document._pending is not None: + raise RuntimeError("nested document transactions are unsupported") + self.document._pending = {} + return self.document + + def __exit__(self, exc_type, exc, tb): + updates = self.document._pending + self.document._pending = None + if exc_type is None and updates: + ocs.update_many(self.label, [dict(handle=handle, **patch) for handle, patch in updates.items()]) + return False + + +class _Document: + def __init__(self): + self._pending = None + self.entities = _Entities(self) + + def transaction(self, label): + return _Transaction(self, label) + + def create_entity(self, kind, **properties): + """Create one mapped entity and return its live document descriptor.""" + if "kind" in properties or "handle" in properties: + raise ValueError("kind and handle are managed by create_entity") + handle = ocs.add(dict(kind=kind, **properties)) + return self.entities[handle] + + def delete_entity(self, entity): + """Delete an entity by descriptor or handle.""" + handle = entity.handle if isinstance(entity, _Entity) else int(entity) + ocs.remove_entity(handle) + + def coverage(self, kind=None): + if kind is not None: + return ocs.entity_coverage(kind) + return {name: ocs.entity_coverage(name) for name in ocs.entity_kinds()} + + def poll_events(self): + return ocs.poll_events(ocs.tab_id()) + + def request_point(self, prompt="Pick a point"): + return ocs.request_input(prompt, False) + + def request_entity(self, prompt="Pick an entity"): + return ocs.request_input(prompt, True) + + def poll_input(self, token): + return ocs.poll_input(token) + + @property + def selection(self): + return [self.entities[handle] for handle in ocs.selection()] + + @selection.setter + def selection(self, entities): + ocs.select([entity.handle if isinstance(entity, _Entity) else int(entity) for entity in entities]) + + +ocs.active_document = _Document() diff --git a/plugins/opencad-python/src/event_cache.rs b/plugins/opencad-python/src/event_cache.rs new file mode 100644 index 000000000..780fba65e --- /dev/null +++ b/plugins/opencad-python/src/event_cache.rs @@ -0,0 +1,223 @@ +//! Bounded, tab-scoped notifications retained between fresh Python runs. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Mutex; + +use ocs_plugin_api::host::HostNotification; + +static EVENTS: Mutex>> = Mutex::new(None); +const CAPACITY_PER_TAB: usize = 256; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Event { + Drawing { + tab_id: u64, + version: u64, + }, + Selection { + tab_id: u64, + handles: Vec, + }, + Command { + tab_id: u64, + command: Option, + }, + Overflow { + tab_id: u64, + dropped: u64, + }, +} + +impl Event { + fn tab_id(&self) -> u64 { + match self { + Event::Drawing { tab_id, .. } + | Event::Selection { tab_id, .. } + | Event::Command { tab_id, .. } + | Event::Overflow { tab_id, .. } => *tab_id, + } + } +} + +#[derive(Default)] +struct TabEvents { + queue: VecDeque, + dropped: u64, +} + +pub(crate) fn observe(notification: &HostNotification) { + if let HostNotification::DocumentTabClosed { tab_id } = notification { + if let Ok(mut lock) = EVENTS.lock() { + if let Some(tabs) = lock.as_mut() { + tabs.remove(tab_id); + } + } + return; + } + let event = match notification { + #[cfg(feature = "experimental-host-model")] + HostNotification::DrawingChanged { tab_id, epoch } => Event::Drawing { + tab_id: *tab_id, + version: *epoch, + }, + HostNotification::SelectionChangedV4 { tab_id, handles } => Event::Selection { + tab_id: *tab_id, + handles: handles.iter().map(|h| h.value()).collect(), + }, + #[cfg(feature = "experimental-host-model")] + HostNotification::CommandStateChanged { tab_id, command } => Event::Command { + tab_id: *tab_id, + command: command.clone(), + }, + _ => return, + }; + if let Ok(mut lock) = EVENTS.lock() { + let tab = lock + .get_or_insert_with(HashMap::new) + .entry(event.tab_id()) + .or_default(); + if tab.queue.len() == CAPACITY_PER_TAB { + tab.queue.pop_front(); + tab.dropped += 1; + } + tab.queue.push_back(event); + } +} + +pub(crate) fn drain_for(tab_id: u64) -> Vec { + let Ok(mut lock) = EVENTS.lock() else { + return Vec::new(); + }; + let Some(tabs) = lock.as_mut() else { + return Vec::new(); + }; + let Some(tab) = tabs.remove(&tab_id) else { + return Vec::new(); + }; + let mut events = Vec::with_capacity(tab.queue.len() + usize::from(tab.dropped > 0)); + if tab.dropped > 0 { + events.push(Event::Overflow { + tab_id, + dropped: tab.dropped, + }); + } + events.extend(tab.queue); + events +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn polling_one_tab_preserves_other_tabs_events() { + observe(&HostNotification::DrawingChanged { + tab_id: 101, + epoch: 1, + }); + observe(&HostNotification::DrawingChanged { + tab_id: 102, + epoch: 2, + }); + assert_eq!( + drain_for(101), + vec![Event::Drawing { + tab_id: 101, + version: 1 + }] + ); + assert_eq!( + drain_for(102), + vec![Event::Drawing { + tab_id: 102, + version: 2 + }] + ); + } + + #[test] + fn noisy_tab_reports_its_own_overflow_without_evicting_another_tab() { + observe(&HostNotification::DrawingChanged { + tab_id: 201, + epoch: 7, + }); + for epoch in 0..=CAPACITY_PER_TAB as u64 { + observe(&HostNotification::DrawingChanged { tab_id: 202, epoch }); + } + assert_eq!( + drain_for(201), + vec![Event::Drawing { + tab_id: 201, + version: 7 + }] + ); + let events = drain_for(202); + assert_eq!(events.len(), CAPACITY_PER_TAB + 1); + assert_eq!( + events[0], + Event::Overflow { + tab_id: 202, + dropped: 1 + } + ); + assert_eq!( + events[1], + Event::Drawing { + tab_id: 202, + version: 1 + } + ); + } + + #[test] + fn closing_tab_discards_its_pending_events() { + observe(&HostNotification::DrawingChanged { + tab_id: 301, + epoch: 3, + }); + observe(&HostNotification::DocumentTabClosed { tab_id: 301 }); + assert!(drain_for(301).is_empty()); + } + + #[test] + fn drawing_selection_and_command_events_keep_delivery_order() { + use ocs_plugin_api::host::Handle; + observe(&HostNotification::DrawingChanged { + tab_id: 401, + epoch: 9, + }); + observe(&HostNotification::SelectionChangedV4 { + tab_id: 401, + handles: vec![Handle::new(17)], + }); + observe(&HostNotification::CommandStateChanged { + tab_id: 401, + command: Some("LINE".into()), + }); + observe(&HostNotification::CommandStateChanged { + tab_id: 401, + command: None, + }); + assert_eq!( + drain_for(401), + vec![ + Event::Drawing { + tab_id: 401, + version: 9 + }, + Event::Selection { + tab_id: 401, + handles: vec![17] + }, + Event::Command { + tab_id: 401, + command: Some("LINE".into()) + }, + Event::Command { + tab_id: 401, + command: None + }, + ] + ); + } +} diff --git a/plugins/opencad-python/src/host_ctx.rs b/plugins/opencad-python/src/host_ctx.rs new file mode 100644 index 000000000..344f36362 --- /dev/null +++ b/plugins/opencad-python/src/host_ctx.rs @@ -0,0 +1,81 @@ +//! Thread-local scratch slot that gives the `ocs` module's native functions +//! access to the `HostApi` for the currently running `PY_EVAL` call. +//! +//! RustPython native functions registered via `#[pymodule]` are plain free +//! functions — `add_native_module` takes a `&'static PyModuleDef`, so there is +//! no per-call closure environment to capture `host: &mut dyn HostApi` in. +//! This is the standard pattern for bridging an embedder's per-call context +//! into embedded callbacks (the same shape as Lua userdata or a C callback's +//! `void *ctx`): stash a pointer for the duration of one interpreter run, and +//! clear it before the run's borrow of `host` ends. + +use std::cell::Cell; + +use ocs_plugin_api::host::HostApi; + +thread_local! { + static CURRENT_HOST: Cell> = const { Cell::new(None) }; + static UNDO_LABEL: Cell<&'static str> = const { Cell::new("") }; + static UNDO_STARTED: Cell = const { Cell::new(false) }; +} + +/// Holds the thread-local host pointer for its lifetime; clears it on drop. +/// Must not outlive the `&mut dyn HostApi` borrow it was constructed from. +pub(crate) struct HostGuard; + +impl HostGuard { + /// `undo_label` is the label a later `ensure_undo_started()` call (from + /// an `ocs` write function) will pass to `host.push_undo` — see its doc + /// comment for why this is one group per script, not one per call. + pub(crate) fn set(host: &mut dyn HostApi, undo_label: &'static str) -> Self { + // SAFETY: erases `host`'s borrow lifetime so the fat pointer fits in a + // `'static` thread_local slot (same layout either way: two words, + // data + vtable). Sound only because `Drop` clears the slot before + // this borrow's real lifetime ends — see `with_host`'s safety note. + let ptr: *mut dyn HostApi = + unsafe { std::mem::transmute::<&mut dyn HostApi, *mut dyn HostApi>(host) }; + CURRENT_HOST.with(|cell| cell.set(Some(ptr))); + UNDO_LABEL.with(|cell| cell.set(undo_label)); + UNDO_STARTED.with(|cell| cell.set(false)); + HostGuard + } +} + +impl Drop for HostGuard { + fn drop(&mut self) { + CURRENT_HOST.with(|cell| cell.set(None)); + } +} + +/// Call from an `ocs` write function (`add_line`, ...) right before it +/// mutates the document. Starts exactly one `host.push_undo` group per +/// `PY_EVAL`/`PY_RUN` call, however many entities the script adds — a script +/// that adds ten lines should undo as one step, not ten. A no-op for a +/// script that never writes, so a pure `PY_EVAL 1 + 1` doesn't clutter the +/// undo stack. +pub(crate) fn ensure_undo_started(host: &mut dyn HostApi) { + UNDO_STARTED.with(|started| { + if !started.get() { + host.push_undo(UNDO_LABEL.with(|label| label.get())); + started.set(true); + } + }); +} + +/// Run `f` with the `HostApi` active for the current `PY_EVAL` call, if any. +/// Returns `None` outside of a `HostGuard`-covered call (a script should never +/// observe this in practice, since the guard spans the whole interpreter run). +/// +/// Not reentrant: `f` must not call `with_host` again itself, since that would +/// materialize two live `&mut dyn HostApi` referring to the same host at once. +/// Fine for Phase 1.2's `selection()`/`get()`, which don't call each other. +pub(crate) fn with_host(f: impl FnOnce(&mut dyn HostApi) -> R) -> Option { + CURRENT_HOST.with(|cell| { + let ptr = cell.get()?; + // SAFETY: `ptr` is only ever set by `HostGuard::set`, which is created + // from a live `&mut dyn HostApi` and cleared (via `Drop`) before that + // borrow's scope ends. The interpreter run this pointer is valid for + // happens entirely on this thread, within that same scope. + Some(f(unsafe { &mut *ptr })) + }) +} diff --git a/plugins/opencad-python/src/input_cache.rs b/plugins/opencad-python/src/input_cache.rs new file mode 100644 index 000000000..dd839835b --- /dev/null +++ b/plugins/opencad-python/src/input_cache.rs @@ -0,0 +1,159 @@ +//! Results of host-driven point/entity picks, retained across Python runs. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use ocs_plugin_api::host::{CommandStep, Handle, InteractiveCommand}; + +static NEXT_ID: AtomicU64 = AtomicU64::new(1); +static RESULTS: Mutex>> = Mutex::new(None); + +#[derive(Clone)] +pub(crate) enum PickResult { + Pending, + Point([f64; 3]), + Entity { handle: u64, point: [f64; 3] }, + Cancelled, +} + +pub(crate) struct PickCommand { + id: u64, + tab_id: u64, + prompt: String, + entity: bool, +} + +pub(crate) fn begin(tab_id: u64, prompt: String, entity: bool) -> (u64, PickCommand) { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + if let Ok(mut lock) = RESULTS.lock() { + let results = lock.get_or_insert_with(HashMap::new); + if results.len() >= 1024 { + if let Some(oldest) = results.keys().min().copied() { + results.remove(&oldest); + } + } + results.insert(id, (tab_id, PickResult::Pending)); + } + ( + id, + PickCommand { + id, + tab_id, + prompt, + entity, + }, + ) +} + +pub(crate) fn take(tab_id: u64, id: u64) -> Option { + let mut lock = RESULTS.lock().ok()?; + let results = lock.as_mut()?; + let (owner, result) = results.get(&id)?; + if *owner != tab_id { + return None; + } + if matches!(result, PickResult::Pending) { + return Some(PickResult::Pending); + } + results.remove(&id).map(|(_, result)| result) +} + +impl PickCommand { + fn finish(&self, result: PickResult) { + if let Ok(mut lock) = RESULTS.lock() { + lock.get_or_insert_with(HashMap::new) + .insert(self.id, (self.tab_id, result)); + } + } +} + +impl Drop for PickCommand { + fn drop(&mut self) { + if let Ok(mut lock) = RESULTS.lock() { + if let Some((owner, PickResult::Pending)) = lock.as_mut().and_then(|r| r.get(&self.id)) + { + if *owner == self.tab_id { + lock.as_mut() + .unwrap() + .insert(self.id, (self.tab_id, PickResult::Cancelled)); + } + } + } + } +} + +impl InteractiveCommand for PickCommand { + fn prompt(&self) -> String { + self.prompt.clone() + } + fn needs_object_pick(&self) -> bool { + self.entity + } + fn on_point(&mut self, point: [f64; 3]) -> CommandStep { + if self.entity { + return CommandStep::NeedPoint; + } + self.finish(PickResult::Point(point)); + CommandStep::Done + } + fn on_object_pick(&mut self, handle: Handle, point: [f64; 3]) -> CommandStep { + if !self.entity { + return CommandStep::NeedPoint; + } + self.finish(PickResult::Entity { + handle: handle.value(), + point, + }); + CommandStep::Done + } + fn on_enter(&mut self) -> CommandStep { + self.finish(PickResult::Cancelled); + CommandStep::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn point_and_entity_picks_complete_tokens() { + let (point_token, mut point) = begin(7, "Point".into(), false); + assert!(matches!(take(7, point_token), Some(PickResult::Pending))); + assert!(take(8, point_token).is_none()); + assert!(matches!(point.on_point([1.0, 2.0, 3.0]), CommandStep::Done)); + assert!(take(8, point_token).is_none()); + assert!(matches!( + take(7, point_token), + Some(PickResult::Point([1.0, 2.0, 3.0])) + )); + let (entity_token, mut entity) = begin(8, "Entity".into(), true); + assert!(entity.needs_object_pick()); + assert!(matches!( + entity.on_object_pick(Handle::new(17), [2.0, 0.0, 0.0]), + CommandStep::Done + )); + assert!(matches!( + take(8, entity_token), + Some(PickResult::Entity { handle: 17, .. }) + )); + } + + #[test] + fn abandoned_pick_reports_cancellation() { + let (token, command) = begin(7, "Point".into(), false); + drop(command); + assert!(take(8, token).is_none()); + assert!(matches!(take(7, token), Some(PickResult::Cancelled))); + } + + #[test] + fn enter_cancels_pick_without_returning_a_point() { + let (token, mut command) = begin(9, "Pick".into(), false); + assert!(matches!(command.on_enter(), CommandStep::Cancel)); + assert!(matches!(take(9, token), Some(PickResult::Cancelled))); + drop(command); + assert!(take(9, token).is_none()); + } +} diff --git a/plugins/opencad-python/src/lib.rs b/plugins/opencad-python/src/lib.rs new file mode 100644 index 000000000..1603fbfe0 --- /dev/null +++ b/plugins/opencad-python/src/lib.rs @@ -0,0 +1,321 @@ +//! Python scripting plugin for Open CAD Studio. +//! +//! Phase 1.2 of `ROADMAP.md`: a read-only `ocs` module (`ocs.selection()`, +//! `ocs.get(handle)`) plus `PY_EVAL ` to evaluate one inline Python +//! expression. Phase 1.3: 2D write access (`ocs.add_line`/`ocs.add_circle`) +//! plus `PY_RUN ` to run a script file. The experimental +//! `ocs.command("...")` / `ocs.select(handles)` functions remain disabled in +//! the bundled build because nested command replay can hang. + +#[cfg(feature = "experimental-host-model")] +mod event_cache; +mod host_ctx; +#[cfg(feature = "experimental-host-model")] +mod input_cache; +mod ocs_module; +mod selection_cache; + +use ocs_plugin_api::host::{BuiltinPlugin, HostApi, HostNotification}; +use ocs_plugin_api::manifest::{ApiVersion, PluginManifest}; +use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; + +pub(crate) const PLUGIN_ID: &str = "opencad.python"; + +// Keep these fields in sync with `plugin.toml`. +static MANIFEST: PluginManifest = PluginManifest { + id: PLUGIN_ID, + name: "Python Scripting", + version: env!("CARGO_PKG_VERSION"), + description: "Embedded Python scripting (RustPython) for Open CAD Studio.", + api_version: ApiVersion::CURRENT, + ribbon_order: 60, + xdata_apps: &[], + command_prefixes: &["PY_"], +}; + +/// The ribbon tab. +struct PythonModule; + +impl CadModule for PythonModule { + fn id(&self) -> &'static str { + "opencad_python" + } + fn title(&self) -> &'static str { + "Python Scripting" + } + fn ribbon_groups(&self) -> &[RibbonGroup] { + static GROUPS: std::sync::OnceLock> = std::sync::OnceLock::new(); + GROUPS.get_or_init(|| { + vec![RibbonGroup { + title: "Tools", + tools: vec![ + RibbonItem::LargeTool(ToolDef { + id: "PY_EVAL", + label: "Eval 1+1", + icon: IconKind::Glyph("★"), + event: ModuleEvent::Command("PY_EVAL 1 + 1".to_string()), + }), + RibbonItem::LargeTool(ToolDef { + id: "PY_RUN", + label: "Run Script…", + icon: IconKind::Glyph("▶"), + event: ModuleEvent::PluginFileDialog { + command: "PY_RUN".to_string(), + title: "Run Python Script".to_string(), + filter_name: "Python Scripts".to_string(), + extensions: vec!["py".to_string()], + }, + }), + ], + }] + }) + } +} + +/// The plugin entry point. +struct PythonPlugin; + +impl BuiltinPlugin for PythonPlugin { + fn manifest(&self) -> &'static PluginManifest { + &MANIFEST + } + fn ribbon(&self) -> Box { + Box::new(PythonModule) + } + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { + if let Some(rest) = cmd.strip_prefix("PY_EVAL") { + let source = rest.trim(); + if source.is_empty() { + host.push_error("PY_EVAL: expected a Python expression, e.g. PY_EVAL 1 + 1"); + } else { + run_eval(host, source); + } + return true; + } + if let Some(rest) = cmd.strip_prefix("PY_RUN") { + let path = rest.trim(); + if path.is_empty() { + host.push_error("PY_RUN: expected a file path, e.g. PY_RUN script.py"); + } else { + run_file(host, path); + } + return true; + } + false + } + + /// The runner's own event loop drains `HostApi::try_recv_notification()` + /// *before* any `Dispatch` request reaches `dispatch()` — calling it from + /// there always sees an empty queue. This callback is the only place a + /// V4+ plugin actually observes notifications; see `selection_cache.rs`. + fn on_notification(&mut self, _command_id: Option, notification: HostNotification) { + #[cfg(feature = "experimental-host-model")] + event_cache::observe(¬ification); + match notification { + HostNotification::SelectionChanged { handles } => selection_cache::set(handles), + HostNotification::SelectionChangedV4 { handles, .. } => selection_cache::set(handles), + _ => {} + } + } +} + +/// Evaluate one Python expression against a fresh interpreter (no session +/// persistence yet — that's a Phase 3 open question) and report the result or +/// error via the command line. A script bug must never look like a silent +/// no-op, so both paths always push something. +fn run_eval(host: &mut dyn HostApi, source: &str) { + let guard = host_ctx::HostGuard::set(host, "PY_EVAL"); + + let builder = rustpython_vm::Interpreter::builder(Default::default()); + let ocs_def = ocs_module::module_def(&builder.ctx); + let interp = builder.add_native_module(ocs_def).build(); + + let outcome = interp.enter(|vm| -> Result<(String, String), String> { + let output = install_output_capture(vm)?; + let scope = vm.new_scope_with_builtins(); + // Pre-bind `ocs` in scope so a one-expression `PY_EVAL` can call + // `ocs.selection()` directly — `Mode::Eval` only accepts a single + // expression, so a script has no way to run an `import` statement + // itself. + let ocs = vm + .import("ocs", 0) + .map_err(|exc| exception_to_string(vm, &exc))?; + scope + .globals + .set_item("ocs", ocs, vm) + .map_err(|exc| exception_to_string(vm, &exc))?; + #[cfg(feature = "experimental-host-model")] + install_document_model(vm, &scope)?; + let code_obj = vm + .compile( + source, + rustpython_vm::compiler::Mode::Eval, + "".to_string(), + ) + .map_err(|err| err.to_string())?; + let result = match vm.run_code_obj(code_obj, scope) { + Ok(result) => result + .str(vm) + .map(|s| s.to_string()) + .map_err(|exc| exception_to_string(vm, &exc)), + Err(exc) => Err(exception_to_string(vm, &exc)), + }?; + Ok((result, captured_output(vm, &output)?)) + }); + + drop(guard); + + match outcome { + Ok((text, printed)) => { + if !printed.is_empty() { + host.push_output(&printed); + } + host.push_output(&text); + } + Err(err) => host.push_error(&err), + } +} + +/// Run a `.py` file (`PY_RUN `) — same interpreter setup as `PY_EVAL`, +/// but `Mode::Exec` (statements, not a single expression) since a file can +/// hold a whole script. Reports errors the same way as `PY_EVAL`; on success +/// reports that the script ran rather than a value (`Mode::Exec` doesn't +/// produce one). +fn run_file(host: &mut dyn HostApi, path: &str) { + let source = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(err) => { + host.push_error(&format!("PY_RUN: cannot read {path}: {err}")); + return; + } + }; + + let guard = host_ctx::HostGuard::set(host, "PY_RUN"); + + let builder = rustpython_vm::Interpreter::builder(Default::default()); + let ocs_def = ocs_module::module_def(&builder.ctx); + let interp = builder.add_native_module(ocs_def).build(); + + let outcome = interp.enter(|vm| -> Result { + let output = install_output_capture(vm)?; + let scope = vm.new_scope_with_builtins(); + let ocs = vm + .import("ocs", 0) + .map_err(|exc| exception_to_string(vm, &exc))?; + scope + .globals + .set_item("ocs", ocs, vm) + .map_err(|exc| exception_to_string(vm, &exc))?; + #[cfg(feature = "experimental-host-model")] + install_document_model(vm, &scope)?; + let code_obj = vm + .compile( + &source, + rustpython_vm::compiler::Mode::Exec, + path.to_string(), + ) + .map_err(|err| err.to_string())?; + vm.run_code_obj(code_obj, scope) + .map_err(|exc| exception_to_string(vm, &exc))?; + captured_output(vm, &output) + }); + + drop(guard); + + match outcome { + Ok(printed) => { + if !printed.is_empty() { + host.push_output(&printed); + } + host.push_output(&format!("PY_RUN: {path} completed")); + } + Err(err) => host.push_error(&err), + } +} + +#[cfg(feature = "experimental-host-model")] +fn install_document_model( + vm: &rustpython_vm::VirtualMachine, + scope: &rustpython_vm::scope::Scope, +) -> Result<(), String> { + let code = vm + .compile( + include_str!("document_model.py"), + rustpython_vm::compiler::Mode::Exec, + "".to_owned(), + ) + .map_err(|error| error.to_string())?; + vm.run_code_obj(code, scope.clone()) + .map_err(|exc| exception_to_string(vm, &exc))?; + Ok(()) +} + +#[cfg(all(test, feature = "experimental-host-model"))] +#[test] +fn document_model_bootstraps_in_rustpython() { + let builder = rustpython_vm::Interpreter::builder(Default::default()); + let ocs_def = ocs_module::module_def(&builder.ctx); + let interp = builder.add_native_module(ocs_def).build(); + interp.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + let ocs = vm.import("ocs", 0).unwrap(); + scope.globals.set_item("ocs", ocs, vm).unwrap(); + install_document_model(vm, &scope).unwrap(); + let code = vm + .compile( + "ocs.active_document.transaction('Move line').label", + rustpython_vm::compiler::Mode::Eval, + "".to_owned(), + ) + .unwrap(); + let label = vm.run_code_obj(code, scope).unwrap(); + assert_eq!(label.str(vm).unwrap().to_string(), "Move line"); + }); +} + +/// RustPython is built without its stdio feature, so sys.stdout/stderr are +/// None by default. Give each fresh interpreter a memory stream for print(). +fn install_output_capture( + vm: &rustpython_vm::VirtualMachine, +) -> Result { + let sys = vm + .import("sys", 0) + .map_err(|exc| exception_to_string(vm, &exc))?; + let io = vm + .import("_io", 0) + .map_err(|exc| exception_to_string(vm, &exc))?; + let output = vm + .call_method(&io, "StringIO", ()) + .map_err(|exc| exception_to_string(vm, &exc))?; + sys.set_attr("stdout", output.clone(), vm) + .map_err(|exc| exception_to_string(vm, &exc))?; + sys.set_attr("stderr", output.clone(), vm) + .map_err(|exc| exception_to_string(vm, &exc))?; + Ok(output) +} + +fn captured_output( + vm: &rustpython_vm::VirtualMachine, + output: &rustpython_vm::PyObjectRef, +) -> Result { + vm.call_method(output, "getvalue", ()) + .and_then(|value| value.str(vm)) + .map(|value| value.to_string().trim_end_matches('\n').to_owned()) + .map_err(|exc| exception_to_string(vm, &exc)) +} + +/// `": "`, the same shape `str(exception)` plus its type +/// name produces in CPython — no full traceback yet (Phase 1.2 is minimal; +/// see the roadmap's error-handling note). +fn exception_to_string( + vm: &rustpython_vm::VirtualMachine, + exc: &rustpython_vm::builtins::PyBaseExceptionRef, +) -> String { + let exc_obj: rustpython_vm::PyObjectRef = exc.clone().into(); + let type_name = exc_obj.class().name().to_string(); + let message = exc_obj.str(vm).map(|s| s.to_string()).unwrap_or_default(); + format!("{type_name}: {message}") +} + +// Emit the C-ABI symbols the host loader looks for. +ocs_plugin_api::export_plugin!(PythonPlugin); diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs new file mode 100644 index 000000000..086afb826 --- /dev/null +++ b/plugins/opencad-python/src/ocs_module.rs @@ -0,0 +1,4865 @@ +//! The `ocs` Python module: read-only entity access (Phase 1.2) plus 2D write +//! access (Phase 1.3) of `docs/python-scripting-roadmap.md`. + +pub(crate) use ocs::module_def; + +#[rustpython_vm::pymodule] +mod ocs { + use std::io::Write; + + #[cfg(feature = "experimental-host-settings")] + use ocs_plugin_api::host::HostSettingValue; + #[cfg(feature = "experimental-host-settings")] + use rustpython_vm::convert::TryFromObject; + use rustpython_vm::{function::ArgIntoFloat, PyObjectRef, PyResult, VirtualMachine}; + + use acadrust::entities::{ + Arc, LwPolyline, LwVertex, MText, TableBuilder, Text, TextHorizontalAlignment, + TextVerticalAlignment, + }; + use acadrust::objects::ObjectType; + use acadrust::types::{Transform, Vector2, Vector3}; + use ocs_plugin_api::host::{ + acadrust, EntityType, ExtendedDataRecord, Handle, ReaderEntityKind, XDataValue, + }; + + use crate::host_ctx; + + /// Write one new UTF-8 report file. The caller must provide an explicit + /// path; existing files are never overwritten and parent directories are + /// not created. + #[pyfunction] + fn write_new_text_file(path: String, content: String, vm: &VirtualMachine) -> PyResult<()> { + if host_ctx::with_host(|_| ()).is_none() { + return Err(vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())); + } + if path.is_empty() { + return Err(vm.new_value_error("report path must not be empty".to_owned())); + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|error| { + vm.new_runtime_error(format!("cannot create report {path}: {error}")) + })?; + if let Err(error) = file.write_all(content.as_bytes()) { + return Err(vm.new_runtime_error(format!( + "cannot finish report {path}: {error}; a partial file may remain" + ))); + } + Ok(()) + } + + fn top_level_blocks(document: &acadrust::CadDocument) -> std::collections::HashSet { + let mut blocks: std::collections::HashSet = document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Layout(layout) if !layout.block_record.is_null() => { + Some(layout.block_record) + } + _ => None, + }) + .collect(); + if !document.header.model_space_block_handle.is_null() { + blocks.insert(document.header.model_space_block_handle); + } + if let Some(model) = document.block_records.get("*Model_Space") { + blocks.insert(model.handle); + } + blocks + } + + /// Handles of the entities selected as of the last `SelectionChangedV4` + /// notification (see `selection_cache.rs` — a best-effort cache, not a + /// live query). + #[pyfunction] + fn selection(vm: &VirtualMachine) -> PyResult { + #[cfg(feature = "experimental-host-model")] + let handles = host_ctx::with_host(|host| host.selection()).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + #[cfg(not(feature = "experimental-host-model"))] + let handles = crate::selection_cache::get(); + let items = handles + .into_iter() + .map(|h| vm.new_pyobj(h.value())) + .collect(); + Ok(vm.ctx.new_list(items).into()) + } + + /// Drain best-effort drawing, selection, and command-state notifications. + /// Events accumulated since the previous poll, up to a bounded queue. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn poll_events(tab_id: u64, vm: &VirtualMachine) -> PyResult { + use crate::event_cache::Event; + let current_tab = host_ctx::with_host(|host| host.tab_id()).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + if tab_id != current_tab { + return Err( + vm.new_value_error("ocs.poll_events: tab_id is not the active document".to_owned()) + ); + } + let mut items = Vec::new(); + for event in crate::event_cache::drain_for(tab_id) { + let row = vm.ctx.new_dict(); + match event { + Event::Drawing { tab_id, version } => { + row.set_item("type", vm.new_pyobj("drawing"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item("version", vm.new_pyobj(version), vm)?; + } + Event::Selection { tab_id, handles } => { + row.set_item("type", vm.new_pyobj("selection"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item( + "handles", + vm.ctx + .new_list(handles.into_iter().map(|h| vm.new_pyobj(h)).collect()) + .into(), + vm, + )?; + } + Event::Command { tab_id, command } => { + row.set_item("type", vm.new_pyobj("command"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item("command", vm.new_pyobj(command), vm)?; + } + Event::Overflow { tab_id, dropped } => { + row.set_item("type", vm.new_pyobj("overflow"), vm)?; + row.set_item("tab_id", vm.new_pyobj(tab_id), vm)?; + row.set_item("dropped", vm.new_pyobj(dropped), vm)?; + } + } + items.push(row.into()); + } + Ok(vm.ctx.new_list(items).into()) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn tab_id(vm: &VirtualMachine) -> PyResult { + host_ctx::with_host(|host| host.tab_id()) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Start a host-driven point or entity pick. Read the result in a later + /// script invocation with `poll_input(token)`. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn request_input(prompt: String, entity: bool, vm: &VirtualMachine) -> PyResult { + if prompt.trim().is_empty() { + return Err(vm.new_value_error("prompt must not be empty".to_owned())); + } + host_ctx::with_host(|host| { + let (token, command) = crate::input_cache::begin(host.tab_id(), prompt, entity); + host.start_interactive(Box::new(command)); + token + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn poll_input(token: u64, vm: &VirtualMachine) -> PyResult { + use crate::input_cache::PickResult; + let tab_id = host_ctx::with_host(|host| host.tab_id()).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(result) = crate::input_cache::take(tab_id, token) else { + return Ok(vm.ctx.none()); + }; + let row = vm.ctx.new_dict(); + match result { + PickResult::Pending => row.set_item("status", vm.new_pyobj("pending"), vm)?, + PickResult::Cancelled => row.set_item("status", vm.new_pyobj("cancelled"), vm)?, + PickResult::Point(point) => { + row.set_item("status", vm.new_pyobj("point"), vm)?; + row.set_item( + "point", + vm.ctx + .new_list(point.into_iter().map(|v| vm.new_pyobj(v)).collect()) + .into(), + vm, + )?; + } + PickResult::Entity { handle, point } => { + row.set_item("status", vm.new_pyobj("entity"), vm)?; + row.set_item("handle", vm.new_pyobj(handle), vm)?; + row.set_item( + "point", + vm.ctx + .new_list(point.into_iter().map(|v| vm.new_pyobj(v)).collect()) + .into(), + vm, + )?; + } + } + Ok(row.into()) + } + + /// Read-only geometry/properties for one entity handle, or `None` if it + /// doesn't exist. Backed by `HostApi::document_reader()` (zero-copy read). + #[pyfunction] + fn get(handle: u64, vm: &VirtualMachine) -> PyResult { + let target = Handle::new(handle); + let found = host_ctx::with_host(|host| { + let reader = host.document_reader(); + let mut found = None; + reader.for_each_entity(&mut |entity| { + if found.is_none() && entity.handle == target { + found = Some((entity.kind, entity.layer_name.to_string(), entity.point)); + } + }); + found + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + + let Some((kind, layer, point)) = found else { + return Ok(vm.ctx.none()); + }; + + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item("kind", vm.new_pyobj(kind_name(kind)), vm)?; + dict.set_item("layer", vm.new_pyobj(layer), vm)?; + let point_obj = match point { + Some(p) => { + let pd = vm.ctx.new_dict(); + pd.set_item("x", vm.new_pyobj(p.x), vm)?; + pd.set_item("y", vm.new_pyobj(p.y), vm)?; + pd.set_item("z", vm.new_pyobj(p.z), vm)?; + pd.into() + } + None => vm.ctx.none(), + }; + dict.set_item("point", point_obj, vm)?; + Ok(dict.into()) + } + + /// Full single-line TEXT geometry from the host's document snapshot. + /// `point` is the active DXF alignment point (10 for left/baseline, + /// otherwise 11), in the entity's OCS coordinate system. + #[pyfunction] + fn get_text(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Text(text)) => Some(text.clone()), + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(text) = found else { + return Ok(vm.ctx.none()); + }; + let point = active_text_point(&text); + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item( + "point", + vm.ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into(), + vm, + )?; + dict.set_item( + "normal", + vm.ctx + .new_list(vec![ + vm.new_pyobj(text.normal.x), + vm.new_pyobj(text.normal.y), + vm.new_pyobj(text.normal.z), + ]) + .into(), + vm, + )?; + dict.set_item("rotation", vm.new_pyobj(text.rotation), vm)?; + dict.set_item( + "direction", + vm.ctx + .new_list(vec![ + vm.new_pyobj(text.rotation.cos()), + vm.new_pyobj(text.rotation.sin()), + ]) + .into(), + vm, + )?; + dict.set_item("height", vm.new_pyobj(text.height), vm)?; + dict.set_item("value", vm.new_pyobj(text.value), vm)?; + dict.set_item("style", vm.new_pyobj(text.style), vm)?; + Ok(dict.into()) + } + + /// Approximate XY box of left/baseline TEXT, using height and character count. + #[pyfunction] + fn text_box_vertices( + handle: u64, + padding: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let padding = padding.into_float(); + if !padding.is_finite() || padding < 0.0 { + return Err(vm.new_value_error( + "ocs.text_box_vertices: padding must be finite and nonnegative".to_owned(), + )); + } + let found = host_ctx::with_host(|host| { + let Some(EntityType::Text(text)) = host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if text.normal != Vector3::UNIT_Z + || text.horizontal_alignment != TextHorizontalAlignment::Left + || text.vertical_alignment != TextVerticalAlignment::Baseline + || text.value.is_empty() + || ![ + text.height, + text.width_factor, + text.rotation, + text.insertion_point.x, + text.insertion_point.y, + text.insertion_point.z, + ] + .iter() + .all(|value| value.is_finite()) + || text.height <= 0.0 + || text.width_factor <= 0.0 + { + return None; + } + let width = text.value.chars().count() as f64 * text.height * 0.6 * text.width_factor; + let local = [ + (-padding, -padding), + (width + padding, -padding), + (width + padding, text.height + padding), + (-padding, text.height + padding), + ]; + let (sin, cos) = text.rotation.sin_cos(); + let vertices = local.map(|(x, y)| { + [ + text.insertion_point.x + x * cos - y * sin, + text.insertion_point.y + x * sin + y * cos, + ] + }); + Some((vertices, text.insertion_point.z)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((vertices, elevation)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + let points = vertices + .into_iter() + .map(|point| { + vm.ctx + .new_list(vec![vm.new_pyobj(point[0]), vm.new_pyobj(point[1])]) + .into() + }) + .collect(); + dict.set_item("vertices", vm.ctx.new_list(points).into(), vm)?; + dict.set_item("elevation", vm.new_pyobj(elevation), vm)?; + Ok(dict.into()) + } + + /// Add a top-left anchored MTEXT entity in the world XY plane. + #[pyfunction] + fn add_mtext( + value: rustpython_vm::builtins::PyStrRef, + point: Vec, + height: ArgIntoFloat, + width: ArgIntoFloat, + rotation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (height, width, rotation) = ( + height.into_float(), + width.into_float(), + rotation.into_float(), + ); + if point.len() != 3 + || point.iter().any(|v| !v.is_finite()) + || ![height, width, rotation].iter().all(|v| v.is_finite()) + || height <= 0.0 + || width <= 0.0 + { + return Err(vm.new_value_error( + "ocs.add_mtext: coordinates must be finite; height and width must be positive" + .to_owned(), + )); + } + let mut text = MText::with_value( + value.to_string(), + Vector3::new(point[0], point[1], point[2]), + ) + .with_height(height) + .with_width(width); + text.rotation = rotation; + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::MText(text)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Delete one entity in the script's shared undo group. + #[pyfunction] + fn remove_entity(handle: u64, vm: &VirtualMachine) -> PyResult<()> { + let removed = host_ctx::with_host(|host| { + if host.document().get_entity(Handle::new(handle)).is_none() { + return false; + } + host_ctx::ensure_undo_started(host); + host.remove_entity(Handle::new(handle)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !removed { + return Err(vm.new_runtime_error(format!( + "ocs.remove_entity: entity {handle} was not deleted" + ))); + } + Ok(()) + } + + /// Read the XDATA record for `app_name` on entity `handle`, or `None` if + /// the entity doesn't exist or carries no such record. Each value is a + /// `{"kind": ..., "value": ...}` dict — see `write_record` for the + /// supported kinds. + /// + /// The `{app_name, values}`/`{kind, value}` shape mirrors + /// `schoeller/ocs_python_repl`'s `ocs.doc.read_record(...)` (its `_ocs` + /// PyO3 extension) so a script's XDATA handling ports between the two + /// plugins unchanged. + #[pyfunction] + fn read_record(handle: u64, app_name: String, vm: &VirtualMachine) -> PyResult { + let record = + host_ctx::with_host(|host| host.read_record(Handle::new(handle), &app_name).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(record) = record else { + return Ok(vm.ctx.none()); + }; + xdata_record_to_py(vm, &record) + } + + /// Attach an XDATA record to `handle` for `app_name`, replacing any + /// existing record for the same application. `values` is a list of + /// `{"kind": ..., "value": ...}` dicts; supported kinds: `String`, + /// `ControlString`, `LayerName`, `BinaryData` (Python `bytes`), `Handle` + /// (`int`), `Point3D`/`Position3D`/`Displacement3D`/`Direction3D` + /// (`[x, y, z]`), `Real`/`Distance`/`ScaleFactor` (`float`), and + /// `Integer16`/`Integer32` (`int`). Raises if `handle` does not exist. + #[pyfunction] + fn write_record( + handle: u64, + app_name: String, + values: Vec, + vm: &VirtualMachine, + ) -> PyResult<()> { + let mut record = ExtendedDataRecord::new(app_name); + for value in values { + let dict = value.try_into_value::(vm)?; + record.add_value(py_to_xdata_value(&dict, vm)?); + } + let written = host_ctx::with_host(|host| { + if host.document().get_entity(Handle::new(handle)).is_none() { + return false; + } + host_ctx::ensure_undo_started(host); + host.write_record(Handle::new(handle), record) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !written { + return Err( + vm.new_runtime_error(format!("ocs.write_record: entity {handle} does not exist")) + ); + } + Ok(()) + } + + /// Remove the XDATA record for `app_name` from `handle`, if any. Returns + /// `True` if a record was actually removed. + #[pyfunction] + fn remove_record(handle: u64, app_name: String, vm: &VirtualMachine) -> PyResult { + host_ctx::with_host(|host| { + if host.read_record(Handle::new(handle), &app_name).is_none() { + return false; + } + host_ctx::ensure_undo_started(host); + host.remove_record(Handle::new(handle), &app_name) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + fn xdata_record_to_py( + vm: &VirtualMachine, + record: &ExtendedDataRecord, + ) -> PyResult { + let dict = vm.ctx.new_dict(); + dict.set_item( + "app_name", + vm.new_pyobj(record.application_name.clone()), + vm, + )?; + let mut values = Vec::with_capacity(record.values.len()); + for value in &record.values { + values.push(xdata_value_to_py(vm, value)?); + } + dict.set_item("values", vm.ctx.new_list(values).into(), vm)?; + Ok(dict.into()) + } + + fn xdata_value_to_py(vm: &VirtualMachine, value: &XDataValue) -> PyResult { + let dict = vm.ctx.new_dict(); + let (kind, py_value): (&str, PyObjectRef) = match value { + XDataValue::String(s) => ("String", vm.new_pyobj(s.clone())), + XDataValue::ControlString(s) => ("ControlString", vm.new_pyobj(s.clone())), + XDataValue::LayerName(s) => ("LayerName", vm.new_pyobj(s.clone())), + XDataValue::BinaryData(b) => ("BinaryData", vm.ctx.new_bytes(b.clone()).into()), + XDataValue::Handle(h) => ("Handle", vm.new_pyobj(h.value())), + XDataValue::Point3D(v) => ("Point3D", vector3_to_py(vm, v)), + XDataValue::Position3D(v) => ("Position3D", vector3_to_py(vm, v)), + XDataValue::Displacement3D(v) => ("Displacement3D", vector3_to_py(vm, v)), + XDataValue::Direction3D(v) => ("Direction3D", vector3_to_py(vm, v)), + XDataValue::Real(r) => ("Real", vm.new_pyobj(*r)), + XDataValue::Distance(d) => ("Distance", vm.new_pyobj(*d)), + XDataValue::ScaleFactor(s) => ("ScaleFactor", vm.new_pyobj(*s)), + XDataValue::Integer16(i) => ("Integer16", vm.new_pyobj(*i)), + XDataValue::Integer32(i) => ("Integer32", vm.new_pyobj(*i)), + }; + dict.set_item("kind", vm.new_pyobj(kind), vm)?; + dict.set_item("value", py_value, vm)?; + Ok(dict.into()) + } + + fn vector3_to_py(vm: &VirtualMachine, v: &Vector3) -> PyObjectRef { + vm.ctx + .new_list(vec![ + vm.new_pyobj(v.x), + vm.new_pyobj(v.y), + vm.new_pyobj(v.z), + ]) + .into() + } + + fn py_to_xdata_value( + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, + ) -> PyResult { + let kind = dict.get_item("kind", vm)?.try_into_value::(vm)?; + let value = dict.get_item("value", vm)?; + match kind.as_str() { + "String" => Ok(XDataValue::String(value.try_into_value(vm)?)), + "ControlString" => Ok(XDataValue::ControlString(value.try_into_value(vm)?)), + "LayerName" => Ok(XDataValue::LayerName(value.try_into_value(vm)?)), + "BinaryData" => Ok(XDataValue::BinaryData(value.try_into_value(vm)?)), + "Handle" => Ok(XDataValue::Handle(Handle::new(value.try_into_value(vm)?))), + "Point3D" => Ok(XDataValue::Point3D(py_to_vector3(value, vm)?)), + "Position3D" => Ok(XDataValue::Position3D(py_to_vector3(value, vm)?)), + "Displacement3D" => Ok(XDataValue::Displacement3D(py_to_vector3(value, vm)?)), + "Direction3D" => Ok(XDataValue::Direction3D(py_to_vector3(value, vm)?)), + "Real" => Ok(XDataValue::Real(value.try_into_value(vm)?)), + "Distance" => Ok(XDataValue::Distance(value.try_into_value(vm)?)), + "ScaleFactor" => Ok(XDataValue::ScaleFactor(value.try_into_value(vm)?)), + "Integer16" => Ok(XDataValue::Integer16(value.try_into_value(vm)?)), + "Integer32" => Ok(XDataValue::Integer32(value.try_into_value(vm)?)), + other => Err(vm.new_value_error(format!( + "unsupported XDATA value kind: {other}; supported: String, ControlString, \ + LayerName, BinaryData, Handle, Point3D, Position3D, Displacement3D, \ + Direction3D, Real, Distance, ScaleFactor, Integer16, Integer32" + ))), + } + } + + fn py_to_vector3(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let coords: Vec = value.try_into_value(vm)?; + if coords.len() != 3 || coords.iter().any(|v| !v.is_finite()) { + return Err(vm.new_value_error( + "XDATA Point3D/Position3D/Displacement3D/Direction3D value must be \ + [finite x, y, z]" + .to_owned(), + )); + } + Ok(Vector3::new(coords[0], coords[1], coords[2])) + } + + /// All top-level single- and multiline text in the current document. + #[pyfunction] + fn text_entities(vm: &VirtualMachine) -> PyResult { + let items = host_ctx::with_host(|host| { + host.document() + .entities() + .filter_map(|entity| match entity { + EntityType::Text(text) => { + Some((text.common.handle.value(), "TEXT", text.value.clone())) + } + EntityType::MText(text) => { + Some((text.common.handle.value(), "MTEXT", text.value.clone())) + } + _ => None, + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(items.len()); + for (handle, kind, value) in items { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item("kind", vm.new_pyobj(kind), vm)?; + dict.set_item("value", vm.new_pyobj(value), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// All top-level block references in the active document. + #[pyfunction] + fn block_references(vm: &VirtualMachine) -> PyResult { + let items = host_ctx::with_host(|host| { + let document = host.document(); + document + .entities() + .filter_map(|entity| match entity { + EntityType::Insert(insert) => { + let raw_name = insert.block_name.clone(); + let record = document + .block_records + .iter() + .find(|block| block.name == raw_name); + let is_xref = record.is_some_and(|block| block.flags.is_xref); + let dynamic_definition = document + .dynamic_definition_for_insert(insert.common.handle) + .and_then(|handle| { + document + .block_records + .iter() + .find(|block| block.handle == handle) + }); + let effective_name = dynamic_definition + .map_or_else(|| raw_name.clone(), |block| block.name.clone()); + let is_dynamic = effective_name != raw_name + || document + .dynamic_visibility_for_insert(insert.common.handle) + .is_some(); + let visibility = document + .dynamic_visibility_for_insert(insert.common.handle) + .and_then(|(definition, parameter)| { + let def_record = document + .block_records + .iter() + .find(|block| block.handle == definition)?; + let anon_record = record?; + let current: std::collections::HashSet = anon_record + .entity_handles + .iter() + .enumerate() + .filter(|(_, handle)| { + document + .get_entity(**handle) + .is_some_and(|entity| !entity.common().invisible) + }) + .map(|(index, _)| index) + .collect(); + parameter + .states + .iter() + .find(|state| { + let visible: std::collections::HashSet = def_record + .entity_handles + .iter() + .enumerate() + .filter(|(_, handle)| { + state.visible_blocks.contains(handle) + }) + .map(|(index, _)| index) + .collect(); + visible == current + }) + .map(|state| state.name.clone()) + }); + Some(( + insert.common.handle.value(), + raw_name, + effective_name, + insert.row_count, + insert.column_count, + is_xref, + is_dynamic, + visibility, + )) + } + _ => None, + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(items.len()); + for (handle, name, effective_name, rows, columns, is_xref, is_dynamic, visibility) in items + { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + dict.set_item("name", vm.new_pyobj(name), vm)?; + dict.set_item("effective_name", vm.new_pyobj(effective_name), vm)?; + dict.set_item("is_xref", vm.new_pyobj(is_xref), vm)?; + dict.set_item("is_dynamic", vm.new_pyobj(is_dynamic), vm)?; + dict.set_item( + "visibility", + visibility.map_or_else(|| vm.ctx.none(), |state| vm.new_pyobj(state)), + vm, + )?; + dict.set_item( + "count", + vm.new_pyobj(u64::from(rows) * u64::from(columns)), + vm, + )?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Layer names, visibility flags, and top-level entity counts. + #[pyfunction] + fn layers(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let layout_blocks = top_level_blocks(document); + let mut counts = std::collections::HashMap::::new(); + for entity in document.entities() { + if layout_blocks.contains(&entity.common().owner_handle) { + *counts.entry(entity.common().layer.clone()).or_default() += 1; + } + } + document + .layers + .iter() + .map(|layer| { + ( + layer.name.clone(), + layer.flags.off, + layer.flags.frozen, + layer.flags.locked, + counts.get(&layer.name).copied().unwrap_or(0), + ) + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (name, off, frozen, locked, count) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("name", vm.new_pyobj(name), vm)?; + dict.set_item("off", vm.new_pyobj(off), vm)?; + dict.set_item("frozen", vm.new_pyobj(frozen), vm)?; + dict.set_item("locked", vm.new_pyobj(locked), vm)?; + dict.set_item("entity_count", vm.new_pyobj(count), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Move a top-level entity to an existing layer without changing its owner. + #[pyfunction] + fn set_entity_layer(handle: u64, layer_name: String, vm: &VirtualMachine) -> PyResult<()> { + let changed = host_ctx::with_host(|host| { + let document = host.document(); + if document.layers.get(&layer_name).is_none() { + return false; + } + let Some(mut entity) = document.get_entity(Handle::new(handle)).cloned() else { + return false; + }; + if !top_level_blocks(document).contains(&entity.common().owner_handle) { + return false; + } + if entity.common().layer == layer_name { + return true; + } + entity.common_mut().layer = layer_name; + host_ctx::ensure_undo_started(host); + host.update_entity(entity) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_value_error(format!( + "ocs.set_entity_layer: top-level entity {handle} or target layer not found" + ))); + } + Ok(()) + } + + /// Export one layer's top-level entities as a new DWG, never overwriting. + #[pyfunction] + fn export_layer_dwg( + layer_name: String, + output_path: String, + vm: &VirtualMachine, + ) -> PyResult { + let path = std::path::Path::new(&output_path); + if path + .extension() + .is_none_or(|extension| !extension.eq_ignore_ascii_case("dwg")) + || path.file_name().is_none() + || path.parent().is_none_or(|parent| !parent.is_dir()) + { + return Err(vm.new_value_error( + "ocs.export_layer_dwg: output must be a .dwg file in an existing directory" + .to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let source = host.document(); + source.layers.get(&layer_name)?; + let layout_blocks = top_level_blocks(source); + let mut snapshot = source.clone(); + let removed: Vec = source + .entities() + .filter(|entity| { + layout_blocks.contains(&entity.common().owner_handle) + && entity.common().layer != layer_name + }) + .map(|entity| entity.common().handle) + .collect(); + for handle in removed { + snapshot.remove_entity(handle); + } + let kept: std::collections::HashSet = snapshot + .entities() + .map(|entity| entity.common().handle) + .collect(); + for record in snapshot.block_records.iter_mut() { + record.entity_handles.retain(|handle| kept.contains(handle)); + } + let count = snapshot + .entities() + .filter(|entity| { + layout_blocks.contains(&entity.common().owner_handle) + && entity.common().layer == layer_name + }) + .count(); + Some((snapshot, count)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error(format!( + "ocs.export_layer_dwg: layer {layer_name:?} does not exist" + )) + })?; + let (snapshot, count) = prepared; + if count == 0 { + return Err(vm.new_value_error(format!( + "ocs.export_layer_dwg: layer {layer_name:?} has no top-level entities" + ))); + } + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .map_err(|error| { + vm.new_runtime_error(format!( + "ocs.export_layer_dwg: cannot create {output_path:?}: {error}" + )) + })?; + if let Err(error) = acadrust::DwgWriter::write_to_writer(&mut file, &snapshot) { + drop(file); + let _ = std::fs::remove_file(path); + return Err( + vm.new_runtime_error(format!("ocs.export_layer_dwg: DWG write failed: {error}")) + ); + } + Ok(count) + } + + /// Export a planned set of layers as new DWGs. If any export fails, + /// remove only files created by this call, leaving pre-existing files + /// untouched. No source drawing changes are made. + #[pyfunction] + fn export_layer_dwgs(exports: Vec>, vm: &VirtualMachine) -> PyResult { + let mut paths = std::collections::HashSet::new(); + if exports + .iter() + .any(|row| row.len() != 2 || !paths.insert(row[1].clone())) + { + return Err(vm.new_value_error( + "ocs.export_layer_dwgs: expected unique [layer, path] pairs".to_owned(), + )); + } + let mut created = Vec::with_capacity(exports.len()); + let mut counts = Vec::with_capacity(exports.len()); + for row in exports { + match export_layer_dwg(row[0].clone(), row[1].clone(), vm) { + Ok(count) => { + created.push(row[1].clone()); + counts.push(count); + } + Err(error) => { + let failed_cleanup: Vec = created + .into_iter() + .filter(|path| std::fs::remove_file(path).is_err()) + .collect(); + if !failed_cleanup.is_empty() { + return Err(vm.new_runtime_error(format!( + "ocs.export_layer_dwgs: export failed ({error:?}); cleanup failed for {failed_cleanup:?}" + ))); + } + return Err(error); + } + } + } + Ok(vm + .ctx + .new_list( + counts + .into_iter() + .map(|count| vm.new_pyobj(count)) + .collect(), + ) + .into()) + } + + /// Name of the active document's current layer (CLAYER). + #[pyfunction] + fn current_layer(vm: &VirtualMachine) -> PyResult { + host_ctx::with_host(|host| host.document().header.current_layer_name.clone()) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Read a host-managed system variable without invoking a CAD command. + #[cfg(feature = "experimental-host-settings")] + #[pyfunction] + fn system_variable(name: String, vm: &VirtualMachine) -> PyResult { + let value = host_ctx::with_host(|host| host.system_variable(&name)).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + Ok(match value { + Some(HostSettingValue::Text(text)) => vm.new_pyobj(text), + Some(HostSettingValue::Number(number)) => vm.new_pyobj(number), + None => vm.ctx.none(), + }) + } + + /// Set a host-managed system variable without nested command dispatch. + #[cfg(feature = "experimental-host-settings")] + #[pyfunction] + fn set_system_variable( + name: String, + value: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + let setting = match value.clone().try_into_value::(vm) { + Ok(text) => HostSettingValue::Text(text), + Err(_) => { + HostSettingValue::Number(ArgIntoFloat::try_from_object(vm, value)?.into_float()) + } + }; + let changed = host_ctx::with_host(|host| host.set_system_variable(&name, setting)) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })? + .map_err(|error| vm.new_value_error(error))?; + Ok(match changed { + HostSettingValue::Text(text) => vm.new_pyobj(text), + HostSettingValue::Number(number) => vm.new_pyobj(number), + }) + } + + /// Owning layout of a top-level entity, including sheet order/count. + #[pyfunction] + fn layout_for_entity(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + let entity = document.get_entity(Handle::new(handle))?; + let block = document + .block_records + .iter() + .find(|record| record.handle == entity.common().owner_handle)?; + let ObjectType::Layout(layout) = document.objects.get(&block.layout)? else { + return None; + }; + let sheet_count = document + .objects + .values() + .filter(|object| matches!(object, ObjectType::Layout(item) if item.name != "Model")) + .count(); + Some((layout.name.clone(), layout.tab_order, sheet_count)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((name, order, sheet_count)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + dict.set_item("name", vm.new_pyobj(name), vm)?; + dict.set_item("order", vm.new_pyobj(order), vm)?; + dict.set_item("sheet_count", vm.new_pyobj(sheet_count), vm)?; + Ok(dict.into()) + } + + /// Tag/value pairs attached to one INSERT, or None for other entities. + #[pyfunction] + fn block_attributes(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Insert(insert)) => Some( + insert + .attributes + .iter() + .map(|attribute| (attribute.tag.clone(), attribute.value.clone())) + .collect::>(), + ), + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(attributes) = found else { + return Ok(vm.ctx.none()); + }; + let mut output = Vec::with_capacity(attributes.len()); + for (tag, value) in attributes { + let dict = vm.ctx.new_dict(); + dict.set_item("tag", vm.new_pyobj(tag), vm)?; + dict.set_item("value", vm.new_pyobj(value), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Replace one INSERT attribute's value by tag, preserving other data. + #[pyfunction] + fn set_block_attribute( + handle: u64, + tag: rustpython_vm::builtins::PyStrRef, + value: rustpython_vm::builtins::PyStrRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + let tag = tag.to_string(); + let value = value.to_string(); + let changed = host_ctx::with_host(|host| { + let Some(EntityType::Insert(mut insert)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + let Some(attribute) = insert + .attributes + .iter_mut() + .find(|attribute| attribute.tag.eq_ignore_ascii_case(&tag)) + else { + return false; + }; + attribute.value = value; + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::Insert(insert)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_block_attribute: INSERT {handle} or tag {tag} was not updated" + ))); + } + Ok(()) + } + + /// Replace the content of one top-level TEXT or MTEXT entity in place. + #[pyfunction] + fn set_text_value( + handle: u64, + value: rustpython_vm::builtins::PyStrRef, + vm: &VirtualMachine, + ) -> PyResult<()> { + let value = value.to_string(); + let changed = host_ctx::with_host(|host| { + let Some(entity) = host.document().get_entity(Handle::new(handle)).cloned() else { + return false; + }; + let updated = match entity { + EntityType::Text(mut text) => { + text.value = value; + EntityType::Text(text) + } + EntityType::MText(mut text) => { + text.value = value; + EntityType::MText(text) + } + _ => return false, + }; + host_ctx::ensure_undo_started(host); + host.update_entity(updated) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_text_value: TEXT/MTEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Set an MTEXT background mode (off, mask, or fill) and border scale. + #[pyfunction] + fn set_mtext_mask( + handle: u64, + mode: rustpython_vm::builtins::PyStrRef, + scale: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let scale = scale.into_float(); + let mode = mode.to_string(); + if !scale.is_finite() || scale < 1.0 || !["off", "mask", "fill"].contains(&mode.as_str()) { + return Err(vm.new_value_error( + "ocs.set_mtext_mask: mode must be off/mask/fill and scale at least 1".to_owned(), + )); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::MText(mut text)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + text.background_fill_flags &= !0x03; + text.background_fill_flags |= match mode.as_str() { + "mask" => 0x02, + "fill" => 0x01, + _ => 0, + }; + text.background_scale = scale; + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::MText(text)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_mtext_mask: MTEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Change only the active insertion/alignment point of a TEXT entity, + /// preserving its style, rotation, content and other properties. + #[pyfunction] + fn move_text( + handle: u64, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let (x, y, z) = (x.into_float(), y.into_float(), z.into_float()); + if ![x, y, z].iter().all(|value| value.is_finite()) { + return Err(vm.new_value_error("ocs.move_text: coordinates must be finite".to_owned())); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::Text(mut text)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + let point = Vector3::new(x, y, z); + if uses_text_alignment_point(&text) { + text.alignment_point = Some(point); + } else { + text.insertion_point = point; + } + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::Text(text)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.move_text: TEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Move and rotate one XY TEXT entity, preserving its other properties. + #[pyfunction] + fn set_text_pose( + handle: u64, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + rotation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let (x, y, z, rotation) = ( + x.into_float(), + y.into_float(), + z.into_float(), + rotation.into_float(), + ); + if ![x, y, z, rotation].iter().all(|v| v.is_finite()) { + return Err(vm.new_value_error( + "ocs.set_text_pose: coordinates and rotation must be finite".to_owned(), + )); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::Text(mut text)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + if text.normal != Vector3::UNIT_Z { + return false; + } + let point = Vector3::new(x, y, z); + if uses_text_alignment_point(&text) { + text.alignment_point = Some(point); + } else { + text.insertion_point = point; + } + text.rotation = rotation; + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::Text(text)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.set_text_pose: XY TEXT entity {handle} was not updated" + ))); + } + Ok(()) + } + + fn uses_text_alignment_point(text: &Text) -> bool { + !(text.horizontal_alignment == TextHorizontalAlignment::Left + && text.vertical_alignment == TextVerticalAlignment::Baseline) + } + + fn active_text_point(text: &Text) -> Vector3 { + if uses_text_alignment_point(text) { + text.alignment_point.unwrap_or(text.insertion_point) + } else { + text.insertion_point + } + } + + /// Area and label point for a circle or a straight, closed lightweight + /// polyline in the world XY plane. Unsupported geometry returns None. + #[pyfunction] + fn get_area(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + match document.get_entity(Handle::new(handle)) { + Some(EntityType::Circle(circle)) + if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 => + { + Some((circle.area(), circle.center)) + } + Some(EntityType::LwPolyline(polyline)) + if polyline.is_closed + && polyline.normal == Vector3::UNIT_Z + && polyline.vertices.len() >= 3 + && polyline.vertices.iter().all(|v| v.bulge == 0.0) => + { + let mut double_area = 0.0; + let mut cx = 0.0; + let mut cy = 0.0; + for index in 0..polyline.vertices.len() { + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let cross = a.x * b.y - b.x * a.y; + double_area += cross; + cx += (a.x + b.x) * cross; + cy += (a.y + b.y) * cross; + } + if double_area.abs() <= f64::EPSILON { + None + } else { + Some(( + double_area.abs() / 2.0, + Vector3::new( + cx / (3.0 * double_area), + cy / (3.0 * double_area), + polyline.elevation, + ), + )) + } + } + _ => None, + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((area, point)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + dict.set_item("area", vm.new_pyobj(area), vm)?; + dict.set_item( + "point", + vm.ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into(), + vm, + )?; + Ok(dict.into()) + } + + /// Length, halfway point, and local tangent for an XY line, circle, arc, + /// or straight-segment lightweight polyline. + #[pyfunction] + fn get_curve_measure(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + match document.get_entity(Handle::new(handle)) { + Some(EntityType::Line(line)) + if line.normal == Vector3::UNIT_Z + && (line.start.z - line.end.z).abs() <= 1e-9 + && line.length() > 0.0 => + { + Some(( + line.length(), + line.midpoint(), + (line.end.y - line.start.y).atan2(line.end.x - line.start.x), + )) + } + Some(EntityType::Circle(circle)) + if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 => + { + Some(( + 2.0 * std::f64::consts::PI * circle.radius, + Vector3::new( + circle.center.x - circle.radius, + circle.center.y, + circle.center.z, + ), + std::f64::consts::FRAC_PI_2, + )) + } + Some(EntityType::Arc(arc)) + if arc.normal == Vector3::UNIT_Z + && arc.radius > 0.0 + && arc.arc_length() > 0.0 => + { + let angle = arc.start_angle + arc.sweep_angle() / 2.0; + Some(( + arc.arc_length(), + arc.midpoint(), + angle + std::f64::consts::FRAC_PI_2, + )) + } + Some(EntityType::LwPolyline(polyline)) + if polyline.normal == Vector3::UNIT_Z + && polyline.vertices.len() >= 2 + && polyline.vertices.iter().all(|v| v.bulge == 0.0) => + { + let segment_count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len() - 1 + }; + let mut segments = Vec::with_capacity(segment_count); + let mut total = 0.0; + for index in 0..segment_count { + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let length = ((b.x - a.x).powi(2) + (b.y - a.y).powi(2)).sqrt(); + segments.push((a, b, length)); + total += length; + } + if total <= 0.0 { + return None; + } + let mut remaining = total / 2.0; + for (a, b, length) in segments { + if length == 0.0 { + continue; + } + if remaining <= length { + let fraction = remaining / length; + let point = Vector3::new( + a.x + (b.x - a.x) * fraction, + a.y + (b.y - a.y) * fraction, + polyline.elevation, + ); + return Some((total, point, (b.y - a.y).atan2(b.x - a.x))); + } + remaining -= length; + } + None + } + _ => None, + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((length, point, rotation)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + dict.set_item("length", vm.new_pyobj(length), vm)?; + dict.set_item( + "point", + vm.ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into(), + vm, + )?; + dict.set_item("rotation", vm.new_pyobj(rotation), vm)?; + dict.set_item( + "tangent", + vm.ctx + .new_list(vec![ + vm.new_pyobj(rotation.cos()), + vm.new_pyobj(rotation.sin()), + ]) + .into(), + vm, + )?; + Ok(dict.into()) + } + + /// Start and end points of a nonzero XY line, or None. + #[pyfunction] + fn line_endpoints(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Line(line)) + if line.normal == Vector3::UNIT_Z + && (line.start.z - line.end.z).abs() <= 1e-9 + && line.length() > 0.0 => + { + Some((line.start, line.end)) + } + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some((start, end)) = found else { + return Ok(vm.ctx.none()); + }; + let output = vm.ctx.new_list(vec![ + vm.ctx + .new_list(vec![ + vm.new_pyobj(start.x), + vm.new_pyobj(start.y), + vm.new_pyobj(start.z), + ]) + .into(), + vm.ctx + .new_list(vec![ + vm.new_pyobj(end.x), + vm.new_pyobj(end.y), + vm.new_pyobj(end.z), + ]) + .into(), + ]); + Ok(output.into()) + } + + /// Insertion point of one top-level block reference, or None. + #[pyfunction] + fn block_insert_point(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Insert(insert)) => Some(insert.insert_point), + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(point) = found else { + return Ok(vm.ctx.none()); + }; + Ok(vm + .ctx + .new_list(vec![ + vm.new_pyobj(point.x), + vm.new_pyobj(point.y), + vm.new_pyobj(point.z), + ]) + .into()) + } + + /// Axis-aligned XY footprint of a simple unrotated block reference. + /// Rejects nested/unsupported definition entities rather than guessing. + #[pyfunction] + fn block_bounds_xy(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::Insert(insert)) = document.get_entity(Handle::new(handle)) else { + return None; + }; + if insert.normal != Vector3::UNIT_Z + || insert.rotation.abs() > 1e-9 + || insert.row_count != 1 + || insert.column_count != 1 + { + return None; + } + let record = document.block_records.get(&insert.block_name)?; + if record.entity_handles.is_empty() { + return None; + } + let base = record.base_point; + let transform = Transform::from_translation(Vector3::new(-base.x, -base.y, -base.z)) + .then(&Transform::from_scaling(Vector3::new( + insert.x_scale(), + insert.y_scale(), + insert.z_scale(), + ))) + .then(&Transform::from_translation(insert.insert_point)); + let mut xmin = f64::INFINITY; + let mut ymin = f64::INFINITY; + let mut xmax = f64::NEG_INFINITY; + let mut ymax = f64::NEG_INFINITY; + for entity_handle in &record.entity_handles { + let mut entity = document.get_entity(*entity_handle)?.clone(); + if !matches!( + entity, + EntityType::Line(_) + | EntityType::Arc(_) + | EntityType::Circle(_) + | EntityType::LwPolyline(_) + ) { + return None; + } + entity.apply_transform(&transform); + let bounds = entity.as_entity().bounding_box(); + if ![bounds.min.x, bounds.min.y, bounds.max.x, bounds.max.y] + .iter() + .all(|value| value.is_finite()) + { + return None; + } + xmin = xmin.min(bounds.min.x); + ymin = ymin.min(bounds.min.y); + xmax = xmax.max(bounds.max.x); + ymax = ymax.max(bounds.max.y); + } + if xmax - xmin <= 1e-9 || ymax - ymin <= 1e-9 { + return None; + } + Some([xmin, ymin, xmax, ymax]) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + match found { + Some(bounds) => Ok(vm + .ctx + .new_list( + bounds + .into_iter() + .map(|value| vm.new_pyobj(value)) + .collect(), + ) + .into()), + None => Ok(vm.ctx.none()), + } + } + + /// Segment metrics for one XY lightweight polyline, including bulge arcs. + #[pyfunction] + fn polyline_segments(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::LwPolyline(polyline)) = document.get_entity(Handle::new(handle)) + else { + return None; + }; + if polyline.normal != Vector3::UNIT_Z || polyline.vertices.len() < 2 { + return None; + } + let count = if polyline.is_closed { + polyline.vertices.len() + } else { + polyline.vertices.len() - 1 + }; + let mut segments = Vec::with_capacity(count); + for index in 0..count { + let a = polyline.vertices[index]; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()]; + let chord = ((b.location.x - a.location.x).powi(2) + + (b.location.y - a.location.y).powi(2)) + .sqrt(); + let radius = if a.bulge.abs() > f64::EPSILON && chord > 0.0 { + Some(chord * (1.0 + a.bulge * a.bulge) / (4.0 * a.bulge.abs())) + } else { + None + }; + let center = radius.map(|_| { + let offset = (1.0 - a.bulge * a.bulge) / (4.0 * a.bulge); + ( + (a.location.x + b.location.x) / 2.0 + - (b.location.y - a.location.y) * offset, + (a.location.y + b.location.y) / 2.0 + + (b.location.x - a.location.x) * offset, + ) + }); + let length = radius.map_or(chord, |r| r * (4.0 * a.bulge.atan()).abs()); + segments.push(( + a.location, + b.location, + a.start_width, + a.end_width, + length, + center, + radius, + )); + } + Some(segments) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some(segments) = found else { + return Ok(vm.ctx.none()); + }; + let mut output = Vec::with_capacity(segments.len()); + for (start, end, start_width, end_width, length, center, radius) in segments { + let dict = vm.ctx.new_dict(); + dict.set_item( + "start", + vm.ctx + .new_list(vec![vm.new_pyobj(start.x), vm.new_pyobj(start.y)]) + .into(), + vm, + )?; + dict.set_item( + "end", + vm.ctx + .new_list(vec![vm.new_pyobj(end.x), vm.new_pyobj(end.y)]) + .into(), + vm, + )?; + dict.set_item("start_width", vm.new_pyobj(start_width), vm)?; + dict.set_item("end_width", vm.new_pyobj(end_width), vm)?; + dict.set_item("length", vm.new_pyobj(length), vm)?; + dict.set_item( + "center", + center.map_or_else( + || vm.ctx.none(), + |(x, y)| { + vm.ctx + .new_list(vec![vm.new_pyobj(x), vm.new_pyobj(y)]) + .into() + }, + ), + vm, + )?; + dict.set_item( + "radius", + radius.map_or_else(|| vm.ctx.none(), |r| vm.new_pyobj(r)), + vm, + )?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Equal-distance samples for an XY line, arc, or circle. + #[pyfunction] + fn curve_samples(handle: u64, segments: usize, vm: &VirtualMachine) -> PyResult { + if !(2..=4096).contains(&segments) { + return Err(vm.new_value_error( + "ocs.curve_samples: segments must be between 2 and 4096".to_owned(), + )); + } + let found = + host_ctx::with_host( + |host| match host.document().get_entity(Handle::new(handle)) { + Some(EntityType::Line(line)) + if line.normal == Vector3::UNIT_Z + && (line.start.z - line.end.z).abs() <= 1e-9 + && line.length() > 0.0 => + { + let points = (0..=segments) + .map(|i| { + let t = i as f64 / segments as f64; + Vector2::new( + line.start.x + (line.end.x - line.start.x) * t, + line.start.y + (line.end.y - line.start.y) * t, + ) + }) + .collect::>(); + Some((points, line.start.z, false)) + } + Some(EntityType::Circle(circle)) + if circle.normal == Vector3::UNIT_Z && circle.radius > 0.0 => + { + let points = (0..segments) + .map(|i| { + let angle = 2.0 * std::f64::consts::PI * i as f64 / segments as f64; + Vector2::new( + circle.center.x + circle.radius * angle.cos(), + circle.center.y + circle.radius * angle.sin(), + ) + }) + .collect::>(); + Some((points, circle.center.z, true)) + } + Some(EntityType::Arc(arc)) + if arc.normal == Vector3::UNIT_Z + && arc.radius > 0.0 + && arc.arc_length() > 0.0 => + { + let points = (0..=segments) + .map(|i| { + let angle = arc.start_angle + + arc.sweep_angle() * i as f64 / segments as f64; + Vector2::new( + arc.center.x + arc.radius * angle.cos(), + arc.center.y + arc.radius * angle.sin(), + ) + }) + .collect::>(); + Some((points, arc.center.z, false)) + } + _ => None, + }, + ) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some((points, elevation, closed)) = found else { + return Ok(vm.ctx.none()); + }; + let vertices = points + .into_iter() + .map(|point| { + vm.ctx + .new_list(vec![vm.new_pyobj(point.x), vm.new_pyobj(point.y)]) + .into() + }) + .collect(); + let dict = vm.ctx.new_dict(); + dict.set_item("vertices", vm.ctx.new_list(vertices).into(), vm)?; + dict.set_item("elevation", vm.new_pyobj(elevation), vm)?; + dict.set_item("closed", vm.new_pyobj(closed), vm)?; + Ok(dict.into()) + } + + /// Modelspace XY outline of a rectangular, unclipped +Z paperspace viewport. + #[pyfunction] + fn viewport_model_outline(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = host_ctx::with_host(|host| { + let Some(EntityType::Viewport(viewport)) = + host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if viewport.id == 1 + || !viewport.clip_boundary_handle.is_null() + || viewport.view_direction != Vector3::UNIT_Z + || ![ + viewport.width, + viewport.height, + viewport.view_height, + viewport.twist_angle, + viewport.view_center.x, + viewport.view_center.y, + viewport.view_target.x, + viewport.view_target.y, + viewport.view_target.z, + ] + .iter() + .all(|value| value.is_finite()) + || viewport.width <= 0.0 + || viewport.height <= 0.0 + || viewport.view_height <= 0.0 + { + return None; + } + let scale = viewport.view_height / viewport.height; + let (sin, cos) = (-viewport.twist_angle).sin_cos(); + let corners = [ + (-viewport.width / 2.0, -viewport.height / 2.0), + (viewport.width / 2.0, -viewport.height / 2.0), + (viewport.width / 2.0, viewport.height / 2.0), + (-viewport.width / 2.0, viewport.height / 2.0), + ]; + let points = corners.map(|(x, y)| { + let dx = x * scale + viewport.view_center.x; + let dy = y * scale + viewport.view_center.y; + [ + viewport.view_target.x + dx * cos - dy * sin, + viewport.view_target.y + dx * sin + dy * cos, + ] + }); + Some((points, viewport.view_target.z)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let Some((points, elevation)) = found else { + return Ok(vm.ctx.none()); + }; + let dict = vm.ctx.new_dict(); + let vertices = points + .into_iter() + .map(|point| { + vm.ctx + .new_list(vec![vm.new_pyobj(point[0]), vm.new_pyobj(point[1])]) + .into() + }) + .collect(); + dict.set_item("vertices", vm.ctx.new_list(vertices).into(), vm)?; + dict.set_item("elevation", vm.new_pyobj(elevation), vm)?; + Ok(dict.into()) + } + + /// Copy supported modelspace XY curves through a rectangular +Z viewport. + #[pyfunction] + fn copy_model_to_paper( + handles: Vec, + viewport_handle: u64, + vm: &VirtualMachine, + ) -> PyResult { + if handles.is_empty() { + return Ok(vm.ctx.new_list(Vec::::new()).into()); + } + let copies = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::Viewport(viewport)) = + document.get_entity(Handle::new(viewport_handle)) + else { + return None; + }; + if viewport.id == 1 + || !viewport.clip_boundary_handle.is_null() + || viewport.view_direction != Vector3::UNIT_Z + || ![ + viewport.width, + viewport.height, + viewport.view_height, + viewport.twist_angle, + viewport.center.x, + viewport.center.y, + viewport.view_center.x, + viewport.view_center.y, + viewport.view_target.x, + viewport.view_target.y, + viewport.view_target.z, + ] + .iter() + .all(|value| value.is_finite()) + || viewport.width <= 0.0 + || viewport.height <= 0.0 + || viewport.view_height <= 0.0 + { + return None; + } + let paper_block = viewport.common.owner_handle; + let block = document + .block_records + .iter() + .find(|record| record.handle == paper_block)?; + let ObjectType::Layout(layout) = document.objects.get(&block.layout)? else { + return None; + }; + if layout.name == "Model" { + return None; + } + let scale = viewport.height / viewport.view_height; + let transform = Transform::from_translation(Vector3::new( + -viewport.view_target.x, + -viewport.view_target.y, + -viewport.view_target.z, + )) + .then(&Transform::from_rotation( + Vector3::UNIT_Z, + viewport.twist_angle, + )) + .then(&Transform::from_scaling(Vector3::new(scale, scale, 1.0))) + .then(&Transform::from_translation(Vector3::new( + viewport.center.x - viewport.view_center.x * scale, + viewport.center.y - viewport.view_center.y * scale, + 0.0, + ))); + let mut copies = Vec::with_capacity(handles.len()); + for handle in handles { + let mut entity = document.get_entity(Handle::new(handle))?.clone(); + if entity.common().owner_handle != document.header.model_space_block_handle { + return None; + } + let planar = match &entity { + EntityType::Line(item) => { + item.normal == Vector3::UNIT_Z + && (item.start.z - viewport.view_target.z).abs() <= 1e-9 + && (item.end.z - viewport.view_target.z).abs() <= 1e-9 + } + EntityType::Arc(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - viewport.view_target.z).abs() <= 1e-9 + } + EntityType::Circle(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - viewport.view_target.z).abs() <= 1e-9 + } + EntityType::LwPolyline(item) => { + item.normal == Vector3::UNIT_Z + && (item.elevation - viewport.view_target.z).abs() <= 1e-9 + } + _ => false, + }; + if !planar { + return None; + } + entity.as_entity_mut().apply_transform(&transform); + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = paper_block; + copies.push(entity); + } + Some(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.copy_model_to_paper: unsupported viewport or source geometry".to_owned(), + ) + })?; + let new_handles = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entities(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if new_handles.iter().any(|handle| handle.is_null()) { + return Err(vm.new_runtime_error( + "ocs.copy_model_to_paper: host did not add every entity".to_owned(), + )); + } + Ok(vm + .ctx + .new_list( + new_handles + .into_iter() + .map(|handle| vm.new_pyobj(handle.value())) + .collect(), + ) + .into()) + } + + /// Mirror supported planar curves across an existing XY LINE, optionally + /// removing the originals. The axis itself is never mirrored or removed. + #[pyfunction] + fn mirror_xy_entities( + handles: Vec, + axis_handle: u64, + erase_source: bool, + vm: &VirtualMachine, + ) -> PyResult { + if handles.is_empty() { + return Ok(vm.ctx.new_list(Vec::::new()).into()); + } + let unique: std::collections::HashSet = handles.iter().copied().collect(); + if unique.len() != handles.len() || unique.contains(&axis_handle) { + return Err(vm.new_value_error( + "ocs.mirror_xy_entities: handles must be unique and exclude the axis".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let document = host.document(); + let Some(EntityType::Line(axis)) = document.get_entity(Handle::new(axis_handle)) else { + return None; + }; + if axis.normal != Vector3::UNIT_Z + || (axis.start.z - axis.end.z).abs() > 1e-9 + || axis.length() <= 0.0 + { + return None; + } + let transform = Transform::from_mirror_line(axis.start, axis.end); + let mut copies = Vec::with_capacity(handles.len()); + for handle in &handles { + let mut entity = document.get_entity(Handle::new(*handle))?.clone(); + let planar = match &entity { + EntityType::Line(item) => { + item.normal == Vector3::UNIT_Z + && (item.start.z - axis.start.z).abs() <= 1e-9 + && (item.end.z - axis.start.z).abs() <= 1e-9 + } + EntityType::Arc(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - axis.start.z).abs() <= 1e-9 + } + EntityType::Circle(item) => { + item.normal == Vector3::UNIT_Z + && (item.center.z - axis.start.z).abs() <= 1e-9 + } + EntityType::LwPolyline(item) => { + item.normal == Vector3::UNIT_Z + && (item.elevation - axis.start.z).abs() <= 1e-9 + } + _ => false, + }; + if !planar { + return None; + } + entity.apply_mirror(&transform); + entity.common_mut().handle = Handle::NULL; + copies.push(entity); + } + Some(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.mirror_xy_entities: axis or source must be supported planar geometry" + .to_owned(), + ) + })?; + let (added, removed_all) = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entities(prepared); + let mut removed_all = true; + if erase_source && added.iter().all(|handle| !handle.is_null()) { + for handle in &handles { + removed_all &= host.remove_entity(Handle::new(*handle)); + } + } + (added, removed_all) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if added.iter().any(|handle| handle.is_null()) { + return Err(vm.new_runtime_error( + "ocs.mirror_xy_entities: host did not add every entity".to_owned(), + )); + } + if !removed_all { + return Err(vm.new_runtime_error( + "ocs.mirror_xy_entities: a source could not be removed; inspect the drawing" + .to_owned(), + )); + } + Ok(vm + .ctx + .new_list( + added + .into_iter() + .map(|handle| vm.new_pyobj(handle.value())) + .collect(), + ) + .into()) + } + + /// Mirror planar curves across the XY line through `point` in `tangent` + /// direction. The calling script can derive these from a curve midpoint. + #[pyfunction] + fn mirror_xy_about_line( + handles: Vec, + point: Vec, + tangent: Vec, + erase_source: bool, + vm: &VirtualMachine, + ) -> PyResult { + if point.len() != 3 + || tangent.len() != 2 + || point + .iter() + .chain(tangent.iter()) + .any(|value| !value.is_finite()) + || tangent[0].hypot(tangent[1]) <= 1e-9 + || handles.len() + != handles + .iter() + .copied() + .collect::>() + .len() + { + return Err(vm.new_value_error( + "ocs.mirror_xy_about_line: invalid point, tangent, or duplicate handles".to_owned(), + )); + } + if handles.is_empty() { + return Ok(vm.ctx.new_list(Vec::::new()).into()); + } + let start = Vector3::new(point[0], point[1], point[2]); + let end = Vector3::new(point[0] + tangent[0], point[1] + tangent[1], point[2]); + let transform = Transform::from_mirror_line(start, end); + let prepared = host_ctx::with_host(|host| { + let mut copies = Vec::with_capacity(handles.len()); + for handle in &handles { + let mut entity = host.document().get_entity(Handle::new(*handle))?.clone(); + let planar = match &entity { + EntityType::Line(item) => item.normal == Vector3::UNIT_Z + && (item.start.z - start.z).abs() <= 1e-9 + && (item.end.z - start.z).abs() <= 1e-9, + EntityType::Arc(item) => item.normal == Vector3::UNIT_Z + && (item.center.z - start.z).abs() <= 1e-9, + EntityType::Circle(item) => item.normal == Vector3::UNIT_Z + && (item.center.z - start.z).abs() <= 1e-9, + EntityType::LwPolyline(item) => item.normal == Vector3::UNIT_Z + && (item.elevation - start.z).abs() <= 1e-9, + _ => false, + }; + if !planar { + return None; + } + entity.apply_mirror(&transform); + entity.common_mut().handle = Handle::NULL; + copies.push(entity); + } + Some(copies) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| vm.new_value_error( + "ocs.mirror_xy_about_line: only planar LINE/ARC/CIRCLE/LWPolyline sources are supported".to_owned(), + ))?; + let (added, removed_all) = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entities(prepared); + let mut removed_all = true; + if erase_source && added.iter().all(|handle| !handle.is_null()) { + for handle in &handles { + removed_all &= host.remove_entity(Handle::new(*handle)); + } + } + (added, removed_all) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if added.iter().any(|handle| handle.is_null()) || !removed_all { + return Err(vm.new_runtime_error( + "ocs.mirror_xy_about_line: host could not mirror every entity; inspect the drawing" + .to_owned(), + )); + } + Ok(vm + .ctx + .new_list( + added + .into_iter() + .map(|handle| vm.new_pyobj(handle.value())) + .collect(), + ) + .into()) + } + + /// Remove the span between two points strictly inside an XY LINE, keeping + /// its first fragment under the original handle and adding the second. + #[pyfunction] + fn break_xy_line( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err(vm.new_value_error( + "ocs.break_xy_line: break points must be finite XY pairs".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::Line(line)) = host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if line.normal != Vector3::UNIT_Z || (line.start.z - line.end.z).abs() > 1e-9 { + return None; + } + let dx = line.end.x - line.start.x; + let dy = line.end.y - line.start.y; + let length_sq = dx * dx + dy * dy; + if length_sq <= 1e-18 { + return None; + } + let parameter = |point: &[f64]| -> Option { + let px = point[0] - line.start.x; + let py = point[1] - line.start.y; + let t = (px * dx + py * dy) / length_sq; + let deviation = (px * dy - py * dx).abs() / length_sq.sqrt(); + if t <= 1e-9 || t >= 1.0 - 1e-9 || deviation > 1e-6 { + None + } else { + Some(t) + } + }; + let a = parameter(&first)?; + let b = parameter(&second)?; + let before = a.min(b); + let after = a.max(b); + let at = + |t: f64| Vector3::new(line.start.x + t * dx, line.start.y + t * dy, line.start.z); + let mut initial = line.clone(); + let mut final_part = line.clone(); + initial.end = at(before); + final_part.start = at(after); + final_part.common.handle = Handle::NULL; + Some((EntityType::Line(initial), EntityType::Line(final_part))) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.break_xy_line: target must be an XY LINE and both points strictly on it" + .to_owned(), + ) + })?; + let result = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entity(prepared.1); + if added.is_null() { + return None; + } + if !host.update_entity(prepared.0) { + host.remove_entity(added); + return None; + } + Some(added.value()) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_runtime_error("ocs.break_xy_line: host could not edit the line".to_owned()) + })?; + Ok(result) + } + + /// Remove the angular span between two interior XY points on an ARC, + /// retaining the first fragment's original handle. + #[pyfunction] + fn break_xy_arc( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err( + vm.new_value_error("ocs.break_xy_arc: points must be finite XY pairs".to_owned()) + ); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::Arc(arc)) = host.document().get_entity(Handle::new(handle)) else { + return None; + }; + if arc.normal != Vector3::UNIT_Z + || arc.radius <= 0.0 + || arc.sweep_angle() <= 1e-9 + || arc.sweep_angle() >= std::f64::consts::TAU - 1e-9 + { + return None; + } + let parameter = |point: &[f64]| -> Option { + let dx = point[0] - arc.center.x; + let dy = point[1] - arc.center.y; + if (dx.hypot(dy) - arc.radius).abs() > 1e-6 { + return None; + } + let angle = dy.atan2(dx); + let t = (angle - arc.start_angle).rem_euclid(std::f64::consts::TAU); + if t <= 1e-9 || t >= arc.sweep_angle() - 1e-9 { + None + } else { + Some(t) + } + }; + let a = parameter(&first)?; + let b = parameter(&second)?; + let mut initial = arc.clone(); + let mut final_part = arc.clone(); + initial.end_angle = arc.start_angle + a.min(b); + final_part.start_angle = arc.start_angle + a.max(b); + final_part.common.handle = Handle::NULL; + Some((EntityType::Arc(initial), EntityType::Arc(final_part))) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.break_xy_arc: target must be a nonfull XY ARC with interior points on it" + .to_owned(), + ) + })?; + let result = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + let added = host.add_entity(prepared.1); + if added.is_null() { + return None; + } + if !host.update_entity(prepared.0) { + host.remove_entity(added); + return None; + } + Some(added.value()) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_runtime_error("ocs.break_xy_arc: host could not edit the arc".to_owned()) + })?; + Ok(result) + } + + /// Replace an XY CIRCLE with the complementary ARC after removing the + /// counterclockwise span from `first` to `second`. + #[pyfunction] + fn break_xy_circle( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult<()> { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err(vm.new_value_error( + "ocs.break_xy_circle: points must be finite XY pairs".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::Circle(circle)) = host.document().get_entity(Handle::new(handle)) + else { + return None; + }; + if circle.normal != Vector3::UNIT_Z || circle.radius <= 0.0 { + return None; + } + let angle = |point: &[f64]| -> Option { + let dx = point[0] - circle.center.x; + let dy = point[1] - circle.center.y; + if (dx.hypot(dy) - circle.radius).abs() > 1e-6 { + None + } else { + Some(dy.atan2(dx).rem_euclid(std::f64::consts::TAU)) + } + }; + let start = angle(&first)?; + let end = angle(&second)?; + if (end - start).rem_euclid(std::f64::consts::TAU) <= 1e-9 { + return None; + } + let mut arc = Arc::from_center_radius_angles(circle.center, circle.radius, end, start); + arc.common = circle.common.clone(); + arc.thickness = circle.thickness; + arc.normal = circle.normal; + Some(EntityType::Arc(arc)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_value_error( + "ocs.break_xy_circle: target must be an XY CIRCLE with distinct points on it" + .to_owned(), + ) + })?; + let updated = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.update_entity(prepared) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !updated { + return Err(vm.new_runtime_error( + "ocs.break_xy_circle: host could not edit the circle".to_owned(), + )); + } + Ok(()) + } + + /// Remove the path interval between two interior points on a straight, + /// zero-width XY lightweight polyline. A closed polyline becomes one open + /// complementary path, under its original handle. + #[pyfunction] + fn break_xy_polyline( + handle: u64, + first: Vec, + second: Vec, + vm: &VirtualMachine, + ) -> PyResult { + if first.len() != 2 + || second.len() != 2 + || first + .iter() + .chain(second.iter()) + .any(|value| !value.is_finite()) + { + return Err(vm.new_value_error( + "ocs.break_xy_polyline: points must be finite XY pairs".to_owned(), + )); + } + let prepared = host_ctx::with_host(|host| { + let Some(EntityType::LwPolyline(polyline)) = host.document().get_entity(Handle::new(handle)) else { + return None; + }; + if polyline.normal != Vector3::UNIT_Z + || polyline.vertices.len() < (if polyline.is_closed { 3 } else { 2 }) + || polyline.vertices.iter().any(|vertex| vertex.bulge != 0.0 + || vertex.start_width != 0.0 || vertex.end_width != 0.0) { + return None; + } + let segment_count = polyline.vertices.len() - if polyline.is_closed { 0 } else { 1 }; + let mut lengths = Vec::with_capacity(segment_count); + let mut total = 0.0; + for index in 0..segment_count { + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let length = (b.x - a.x).hypot(b.y - a.y); + lengths.push(length); + total += length; + } + if total <= 1e-9 { + return None; + } + let locate = |point: &[f64]| -> Option<(usize, f64)> { + let mut walked = 0.0; + for (index, length) in lengths.iter().copied().enumerate() { + if length <= 1e-9 { + continue; + } + let a = polyline.vertices[index].location; + let b = polyline.vertices[(index + 1) % polyline.vertices.len()].location; + let dx = b.x - a.x; + let dy = b.y - a.y; + let px = point[0] - a.x; + let py = point[1] - a.y; + let fraction = (px * dx + py * dy) / (length * length); + let deviation = (px * dy - py * dx).abs() / length; + let position = walked + fraction * length; + if fraction > 1e-9 && fraction < 1.0 - 1e-9 + && deviation <= 1e-6 && position > 1e-9 + && position < total - 1e-9 { + return Some((index, position)); + } + walked += length; + } + None + }; + let a = locate(&first)?; + let b = locate(&second)?; + if (first[0] - second[0]).hypot(first[1] - second[1]) <= 1e-9 { + return None; + } + if polyline.is_closed { + let mut complement = polyline.clone(); + let mut vertices = vec![LwVertex::new(Vector2::new(second[0], second[1]))]; + if b.0 != a.0 || b.1 > a.1 { + let mut index = (b.0 + 1) % polyline.vertices.len(); + loop { + vertices.push(polyline.vertices[index]); + if index == a.0 { + break; + } + index = (index + 1) % polyline.vertices.len(); + } + } + vertices.push(LwVertex::new(Vector2::new(first[0], first[1]))); + complement.vertices = vertices; + complement.is_closed = false; + return Some((EntityType::LwPolyline(complement), None)); + } + let ((before_index, before_point), (after_index, after_point)) = + if a.1 <= b.1 { ((a.0, &first), (b.0, &second)) } + else { ((b.0, &second), (a.0, &first)) }; + let mut initial = polyline.clone(); + initial.vertices.truncate(before_index + 1); + initial.vertices.push(LwVertex::new(Vector2::new(before_point[0], before_point[1]))); + let mut final_part = polyline.clone(); + final_part.vertices = vec![LwVertex::new(Vector2::new(after_point[0], after_point[1]))]; + final_part.vertices.extend_from_slice(&polyline.vertices[after_index + 1..]); + final_part.common.handle = Handle::NULL; + Some((EntityType::LwPolyline(initial), Some(EntityType::LwPolyline(final_part)))) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| vm.new_value_error( + "ocs.break_xy_polyline: target must be a straight zero-width XY polyline with distinct interior points".to_owned(), + ))?; + let result = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + if let Some(final_part) = prepared.1 { + let added = host.add_entity(final_part); + if added.is_null() { + return None; + } + if !host.update_entity(prepared.0) { + host.remove_entity(added); + return None; + } + Some(added.value()) + } else if host.update_entity(prepared.0) { + Some(handle) + } else { + None + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| { + vm.new_runtime_error( + "ocs.break_xy_polyline: host could not edit the polyline".to_owned(), + ) + })?; + Ok(result) + } + + /// Rigidly move and rotate planar curves, mapping one source XY point to + /// one destination point. Optionally add transformed copies instead of + /// changing the originals. This intentionally does not scale geometry. + #[pyfunction] + fn align_xy_entities( + handles: Vec, + source: Vec, + destination: Vec, + source_direction: Vec, + target_direction: Vec, + copy_mode: bool, + vm: &VirtualMachine, + ) -> PyResult { + if source.len() != 3 + || destination.len() != 3 + || source_direction.len() != 2 + || target_direction.len() != 2 + || source + .iter() + .chain(destination.iter()) + .chain(source_direction.iter()) + .chain(target_direction.iter()) + .any(|value| !value.is_finite()) + || handles.len() + != handles + .iter() + .copied() + .collect::>() + .len() + { + return Err(vm.new_value_error( + "ocs.align_xy_entities: invalid points, directions, or duplicate handles" + .to_owned(), + )); + } + let source_length = source_direction[0].hypot(source_direction[1]); + let target_length = target_direction[0].hypot(target_direction[1]); + if source_length <= 0.0 || target_length <= 0.0 { + return Err( + vm.new_value_error("ocs.align_xy_entities: directions must be nonzero".to_owned()) + ); + } + if handles.is_empty() { + return Ok(0); + } + let rotation = target_direction[1].atan2(target_direction[0]) + - source_direction[1].atan2(source_direction[0]); + let source = Vector3::new(source[0], source[1], source[2]); + let destination = Vector3::new(destination[0], destination[1], destination[2]); + let transform = Transform::from_translation(Vector3::new(-source.x, -source.y, -source.z)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, rotation)) + .then(&Transform::from_translation(destination)); + let mut prepared = host_ctx::with_host(|host| { + let mut entities = Vec::with_capacity(handles.len()); + for handle in &handles { + let mut entity = host.document().get_entity(Handle::new(*handle))?.clone(); + let planar = match &entity { + EntityType::Line(item) => item.normal == Vector3::UNIT_Z + && (item.start.z - item.end.z).abs() <= 1e-9, + EntityType::Arc(item) => item.normal == Vector3::UNIT_Z, + EntityType::Circle(item) => item.normal == Vector3::UNIT_Z, + EntityType::LwPolyline(item) => item.normal == Vector3::UNIT_Z, + _ => false, + }; + if !planar { + return None; + } + entity.apply_transform(&transform); + entities.push(entity); + } + Some(entities) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .ok_or_else(|| vm.new_value_error( + "ocs.align_xy_entities: only planar LINE/ARC/CIRCLE/LWPolyline sources are supported".to_owned(), + ))?; + if copy_mode { + for entity in &mut prepared { + entity.common_mut().handle = Handle::NULL; + } + } + let updated = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + if copy_mode { + host.add_entities(prepared) + .iter() + .all(|handle| !handle.is_null()) + } else { + prepared + .into_iter() + .all(|entity| host.update_entity(entity)) + } + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !updated { + return Err(vm.new_runtime_error( + "ocs.align_xy_entities: host could not write every entity; inspect the drawing" + .to_owned(), + )); + } + Ok(handles.len()) + } + + /// Replace an XY line, arc, or circle with a lightweight polyline, + /// retaining its handle, layer, and common drawing properties. + #[pyfunction] + fn replace_with_polyline( + handle: u64, + vertices: Vec>, + elevation: ArgIntoFloat, + closed: bool, + vm: &VirtualMachine, + ) -> PyResult<()> { + let elevation = elevation.into_float(); + if vertices.len() < (if closed { 3 } else { 2 }) + || vertices + .iter() + .any(|point| point.len() != 2 || point.iter().any(|value| !value.is_finite())) + || !elevation.is_finite() + { + return Err(vm.new_value_error( + "ocs.replace_with_polyline: invalid vertices or elevation".to_owned(), + )); + } + let changed = host_ctx::with_host(|host| { + let Some(entity) = host.document().get_entity(Handle::new(handle)).cloned() else { + return false; + }; + let supported = match &entity { + EntityType::Line(line) => { + line.normal == Vector3::UNIT_Z && (line.start.z - line.end.z).abs() <= 1e-9 + } + EntityType::Arc(arc) => arc.normal == Vector3::UNIT_Z, + EntityType::Circle(circle) => circle.normal == Vector3::UNIT_Z, + _ => false, + }; + if !supported { + return false; + } + let mut polyline = LwPolyline::new(); + polyline.common = entity.common().clone(); + polyline.elevation = elevation; + polyline.is_closed = closed; + for point in vertices { + polyline + .vertices + .push(LwVertex::new(Vector2::new(point[0], point[1]))); + } + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::LwPolyline(polyline)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!( + "ocs.replace_with_polyline: entity {handle} was not updated" + ))); + } + Ok(()) + } + + /// Create a native OCS table from a rectangular grid of cell strings. + #[pyfunction] + fn add_table( + rows: Vec>, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + row_height: ArgIntoFloat, + column_width: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (x, y, z, row_height, column_width) = ( + x.into_float(), + y.into_float(), + z.into_float(), + row_height.into_float(), + column_width.into_float(), + ); + let columns = rows.first().map_or(0, Vec::len); + if rows.is_empty() + || columns == 0 + || rows.iter().any(|row| row.len() != columns) + || ![x, y, z, row_height, column_width] + .iter() + .all(|v| v.is_finite()) + || row_height <= 0.0 + || column_width <= 0.0 + { + return Err(vm.new_value_error("ocs.add_table: rows must be rectangular and nonempty; dimensions must be positive and finite".to_owned())); + } + let mut builder = TableBuilder::new(rows.len(), columns) + .at(Vector3::new(x, y, z)) + .row_height(row_height) + .column_width(column_width); + for (row_index, row) in rows.iter().enumerate() { + for (column_index, value) in row.iter().enumerate() { + builder = builder.cell_text(row_index, column_index, value); + } + } + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Table(builder.build())).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a 2D line from `(x1, y1)` to `(x2, y2)` on layer `"0"`, returning + /// its handle. Starts the script's shared undo group (see + /// `host_ctx::ensure_undo_started`) if this is the first write this call. + /// + /// Coordinates take `ArgIntoFloat` rather than a bare `f64` so plain + /// Python ints work too (`ocs.add_line(0, 0, 10, 10)`) — a bare `f64` + /// parameter in RustPython, unlike CPython's C-function argument parsing, + /// does *not* implicitly coerce an `int`, and rejects it with a `TypeError`. + #[pyfunction] + fn add_line( + x1: ArgIntoFloat, + y1: ArgIntoFloat, + x2: ArgIntoFloat, + y2: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let line = acadrust::Line::from_coords( + x1.into_float(), + y1.into_float(), + 0.0, + x2.into_float(), + y2.into_float(), + 0.0, + ); + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Line(line)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a 2D circle centered at `(x, y)` with the given `radius`, on layer + /// `"0"`, returning its handle. Same undo-grouping and int/float argument + /// handling as `add_line`. + #[pyfunction] + fn add_circle( + x: ArgIntoFloat, + y: ArgIntoFloat, + radius: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let circle = + acadrust::Circle::from_coords(x.into_float(), y.into_float(), 0.0, radius.into_float()); + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Circle(circle)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a 2D arc centered at `(x, y)`, from `start_deg` to `end_deg` + /// (degrees, counterclockwise from the +X axis — matching how OCS's own + /// `ARC`/angle fields are conventionally read), on layer `"0"`. Returns + /// its handle. Same undo-grouping and int/float argument handling as + /// `add_line`. + #[pyfunction] + fn add_arc( + x: ArgIntoFloat, + y: ArgIntoFloat, + radius: ArgIntoFloat, + start_deg: ArgIntoFloat, + end_deg: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let center = acadrust::Vector3::new(x.into_float(), y.into_float(), 0.0); + let arc = acadrust::Arc::from_center_radius_angles( + center, + radius.into_float(), + start_deg.into_float().to_radians(), + end_deg.into_float().to_radians(), + ); + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Arc(arc)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add an open 2D lightweight polyline. Each vertex is + /// [x, y, start_width, end_width]; widths belong to the segment starting + /// at that vertex. The polyline uses a single explicit elevation. + #[pyfunction] + fn add_polyline( + vertices: Vec>, + elevation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let elevation = elevation.into_float(); + if vertices.len() < 2 || !elevation.is_finite() { + return Err(vm.new_value_error( + "ocs.add_polyline: need at least two vertices and a finite elevation".to_owned(), + )); + } + let mut polyline = LwPolyline::new(); + polyline.elevation = elevation; + for vertex in vertices { + if vertex.len() != 4 + || vertex.iter().any(|value| !value.is_finite()) + || vertex[2] < 0.0 + || vertex[3] < 0.0 + { + return Err(vm.new_value_error("ocs.add_polyline: each vertex must be [finite x, y, nonnegative start_width, end_width]".to_owned())); + } + let mut item = LwVertex::new(Vector2::new(vertex[0], vertex[1])); + item.start_width = vertex[2]; + item.end_width = vertex[3]; + polyline.vertices.push(item); + } + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::LwPolyline(polyline)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add a closed XY lightweight polyline with vertices `[x, y]`. + #[pyfunction] + fn add_closed_polyline( + vertices: Vec>, + elevation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let elevation = elevation.into_float(); + if vertices.len() < 3 + || !elevation.is_finite() + || vertices + .iter() + .any(|point| point.len() != 2 || point.iter().any(|v| !v.is_finite())) + { + return Err(vm.new_value_error("ocs.add_closed_polyline: need at least three finite [x, y] vertices and finite elevation".to_owned())); + } + let mut polyline = LwPolyline::new(); + polyline.elevation = elevation; + polyline.is_closed = true; + for point in vertices { + polyline + .vertices + .push(LwVertex::new(Vector2::new(point[0], point[1]))); + } + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::LwPolyline(polyline)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Replace a closed lightweight polyline's shape, preserving handle/properties/XDATA. + #[pyfunction] + fn update_closed_polyline( + handle: u64, + vertices: Vec>, + elevation: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult<()> { + let elevation = elevation.into_float(); + if vertices.len() < 3 + || !elevation.is_finite() + || vertices + .iter() + .any(|point| point.len() != 2 || point.iter().any(|v| !v.is_finite())) + { + return Err(vm.new_value_error("ocs.update_closed_polyline: need at least three finite [x, y] vertices and finite elevation".to_owned())); + } + let changed = host_ctx::with_host(|host| { + let Some(EntityType::LwPolyline(mut polyline)) = + host.document().get_entity(Handle::new(handle)).cloned() + else { + return false; + }; + if !polyline.is_closed { + return false; + } + polyline.elevation = elevation; + polyline.vertices = vertices + .into_iter() + .map(|point| LwVertex::new(Vector2::new(point[0], point[1]))) + .collect(); + host_ctx::ensure_undo_started(host); + host.update_entity(EntityType::LwPolyline(polyline)) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + if !changed { + return Err(vm.new_runtime_error(format!("ocs.update_closed_polyline: entity {handle} is not a writable closed lightweight polyline"))); + } + Ok(()) + } + + /// Add many 3D points in one host round trip, each `[x, y, z]`. Returns + /// the new handles in the same order as `points`. + /// + /// Ported from `schoeller/ocs_python_repl`'s `ocs.doc.add_many(...)`, + /// which batches thousands of points into a single request instead of + /// one per point. That plugin is out-of-process (real round-trip cost + /// per call); this one is in-process, so the win here is different but + /// still real: `HostApi::add_entities` lets the host coalesce the + /// undo/dirty/geometry-rebuild bookkeeping across the whole batch + /// instead of repeating it once per point (its default implementation + /// merely loops `add_entity`, but hosts are expected to override it for + /// batch efficiency — see its doc comment in `ocs_plugin_api`). + #[pyfunction] + fn add_points(points: Vec>, vm: &VirtualMachine) -> PyResult { + if points.is_empty() { + return Err(vm.new_value_error("ocs.add_points: need at least one point".to_owned())); + } + let mut entities = Vec::with_capacity(points.len()); + for point in &points { + if point.len() != 3 || point.iter().any(|value| !value.is_finite()) { + return Err(vm.new_value_error( + "ocs.add_points: each point must be [finite x, y, z]".to_owned(), + )); + } + entities.push(EntityType::Point(acadrust::Point::from_coords( + point[0], point[1], point[2], + ))); + } + let handles = host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entities(entities) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let items = handles + .into_iter() + .map(|h| vm.new_pyobj(h.value())) + .collect(); + Ok(vm.ctx.new_list(items).into()) + } + + /// Add one left/baseline-aligned single-line TEXT entity. + /// Its insertion point is the supplied point, which also makes it + /// eligible for `text_box_vertices` and the associated-textbox port. + #[pyfunction] + fn add_text_left( + value: rustpython_vm::builtins::PyStrRef, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + height: ArgIntoFloat, + rotation_radians: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (x, y, z, height, rotation_radians) = ( + x.into_float(), + y.into_float(), + z.into_float(), + height.into_float(), + rotation_radians.into_float(), + ); + if ![x, y, z, height, rotation_radians] + .iter() + .all(|v| v.is_finite()) + || height <= 0.0 + { + return Err(vm.new_value_error("ocs.add_text_left: coordinates and rotation must be finite; height must be positive".to_owned())); + } + let mut text = Text::with_value(value.to_string(), Vector3::new(x, y, z)) + .with_height(height) + .with_rotation(rotation_radians); + text.horizontal_alignment = TextHorizontalAlignment::Left; + text.vertical_alignment = TextVerticalAlignment::Baseline; + text.alignment_point = None; + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Text(text)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Add one centered, middle-aligned single-line TEXT entity. + #[pyfunction] + fn add_text_centered( + value: rustpython_vm::builtins::PyStrRef, + x: ArgIntoFloat, + y: ArgIntoFloat, + z: ArgIntoFloat, + height: ArgIntoFloat, + rotation_radians: ArgIntoFloat, + vm: &VirtualMachine, + ) -> PyResult { + let (x, y, z, height, rotation_radians) = ( + x.into_float(), + y.into_float(), + z.into_float(), + height.into_float(), + rotation_radians.into_float(), + ); + if ![x, y, z, height, rotation_radians] + .iter() + .all(|v| v.is_finite()) + || height <= 0.0 + { + return Err(vm.new_value_error("ocs.add_text_centered: coordinates and rotation must be finite; height must be positive".to_owned())); + } + let point = Vector3::new(x, y, z); + let mut text = Text::with_value(value.to_string(), point) + .with_height(height) + .with_rotation(rotation_radians); + text.alignment_point = Some(point); + text.horizontal_alignment = TextHorizontalAlignment::Center; + text.vertical_alignment = TextVerticalAlignment::Middle; + host_ctx::with_host(|host| { + host_ctx::ensure_undo_started(host); + host.add_entity(EntityType::Text(text)).value() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) + } + + /// Run a full command line (built-in or plugin) to completion, exactly + /// as if typed — including any inline point/keyword/handle tokens an + /// interactive command needs (e.g. `ocs.command("LINE 0,0 10,10")`, or + /// `ocs.command(f"PCONSTRAINT {h1:X} {h2:X}")` with two entity handles + /// for a constraint command's object picks). Raises a Python exception + /// on failure — including on hosts that predate `HostApi::run_command` + /// (Phase 1, `ocs.command()` — see `docs/python-scripting-roadmap.md` + /// in the OpenCADStudio repo for what this can and can't do). + #[cfg(feature = "experimental-command-replay")] + #[pyfunction] + fn command(cmd: rustpython_vm::builtins::PyStrRef, vm: &VirtualMachine) -> PyResult<()> { + let cmd = cmd.to_string(); + let result = host_ctx::with_host(|host| host.run_command(&cmd)).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + result.map_err(|e| vm.new_runtime_error(format!("ocs.command({cmd:?}): {e}"))) + } + + /// Set the document's selection to exactly these entity handles + /// (clearing any existing selection first). The missing piece for + /// driving a constraint command via `ocs.command()`: those read a prior + /// selection rather than picks fed as command-line tokens. Errors, + /// without partially applying, if any handle doesn't exist. + #[cfg(any( + feature = "experimental-command-replay", + feature = "experimental-host-model" + ))] + #[pyfunction] + fn select(handles: Vec, vm: &VirtualMachine) -> PyResult<()> { + let handles: Vec = handles.into_iter().map(Handle::new).collect(); + let result = host_ctx::with_host(|host| host.set_selection(&handles)).ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + result.map_err(|e| vm.new_runtime_error(format!("ocs.select(...): {e}"))) + } + + fn kind_name(kind: ReaderEntityKind) -> &'static str { + match kind { + ReaderEntityKind::Point => "point", + ReaderEntityKind::Line => "line", + ReaderEntityKind::Circle => "circle", + ReaderEntityKind::Arc => "arc", + ReaderEntityKind::Polyline => "polyline", + ReaderEntityKind::Text => "text", + ReaderEntityKind::Other => "other", + } + } + + // ════════════════════════════════════════════════════════════════════ + // Generic entity CRUD (Phase 0 — see this session's + // `project_opencad_python_generic_entity_crud_plan.md` memory). + // + // `dict_to_entity`/`entity_to_dict` below are generated at build time + // (`build.rs` + `build/generate.rs`, driven by `entity_manifest.json` + // plus the type registry `ocs_plugin_api` embeds) from the registry's + // 12 currently-traced entity kinds, wired into `ocs.add` / `ocs.update` + // and the feature-gated document model. Round trips are exercised by + // `entity_crud_tests` below. + // + // A handful of leaf conversions the generated code calls into are + // hand-written here rather than generated, matching how `vector3_to_py` + // /`py_to_vector3` above are already hand-written for XDATA: `Vector3`/ + // `Vector2`/`Handle` have no registry-driven struct shape worth + // generating (they're represented as `{"x":..,"y":..,"z":..}` / + // `{"x":..,"y":..}` dicts, matching `get()`'s existing `point` dict — + // not the `[x, y, z]` list XDATA uses), and `get_opt_*` are tiny, + // reused by every generated setter. + + fn vector2_to_py(vm: &VirtualMachine, v: &Vector2) -> PyResult { + let dict = vm.ctx.new_dict(); + dict.set_item("x", vm.new_pyobj(v.x), vm)?; + dict.set_item("y", vm.new_pyobj(v.y), vm)?; + Ok(dict.into()) + } + + fn py_to_vector2(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dict = value.try_into_value::(vm)?; + let x = get_opt_f64(&dict, "x", vm)?; + let y = get_opt_f64(&dict, "y", vm)?; + Ok(Vector2::new(x, y)) + } + + fn vector3_to_py_dict(vm: &VirtualMachine, v: &Vector3) -> PyResult { + let dict = vm.ctx.new_dict(); + dict.set_item("x", vm.new_pyobj(v.x), vm)?; + dict.set_item("y", vm.new_pyobj(v.y), vm)?; + dict.set_item("z", vm.new_pyobj(v.z), vm)?; + Ok(dict.into()) + } + + fn py_to_vector3_dict(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dict = value.try_into_value::(vm)?; + let x = get_opt_f64(&dict, "x", vm)?; + let y = get_opt_f64(&dict, "y", vm)?; + let z = get_opt_f64(&dict, "z", vm)?; + Ok(Vector3::new(x, y, z)) + } + + fn get_opt_f64( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(0.0), + Some(v) if vm.is_none(&v) => Ok(0.0), + Some(v) => v.try_into_value::(vm), + } + } + + fn get_opt_bool( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(false), + Some(v) if vm.is_none(&v) => Ok(false), + Some(v) => v.try_into_value::(vm), + } + } + + fn get_opt_u64( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(0), + Some(v) if vm.is_none(&v) => Ok(0), + Some(v) => v.try_into_value::(vm), + } + } + + fn get_opt_string( + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + default: &str, + vm: &VirtualMachine, + ) -> PyResult { + match dict.get_item_opt(key, vm)? { + None => Ok(default.to_owned()), + Some(v) if vm.is_none(&v) => Ok(default.to_owned()), + Some(v) => v.try_into_value::(vm), + } + } + + fn ensure_known_entity_keys( + dict: &rustpython_vm::builtins::PyDictRef, + kind: &str, + allowed: &[&str], + vm: &VirtualMachine, + ) -> PyResult<()> { + for key in dict.keys_vec() { + let key = key.try_into_value::(vm)?; + if !allowed.contains(&key.as_str()) { + return Err(vm.new_value_error(format!( + "ocs: {kind} property {key:?} is outside the editable schema" + ))); + } + } + Ok(()) + } + + include!(concat!(env!("OUT_DIR"), "/entity_crud.rs")); + + // ════════════════════════════════════════════════════════════════════ + // Phase 1 — wiring the generic conversion functions above into `ocs`. + // + // `get_entity` is deliberately a *new*, separate function rather than a + // change to `get()` above: existing scripts already depend on `get()`'s + // small `{handle, kind, layer, point}` shape (Felix's Phase 1 review — + // don't break that compatibility to hand out the fuller schema). + + /// Full entity schema for `handle` (every field the type registry knows + /// about, not just `get()`'s small `{handle, kind, layer, point}`), or + /// `None` if the entity doesn't exist or isn't one of the kinds this + /// generic API covers yet. + #[pyfunction] + fn get_entity(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + match found { + Some(entity) => entity_to_dict(vm, &entity), + None => Ok(vm.ctx.none()), + } + } + + /// API v7 document-model view: every entity has identity, kind and layer; + /// only manifest-covered kinds also expose editable geometry properties. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_descriptor(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(entity) = found else { + return Ok(vm.ctx.none()); + }; + if let Ok(full) = entity_to_dict(vm, &entity) { + let dict = full + .clone() + .try_into_value::(vm)?; + dict.set_item("_editable", vm.new_pyobj(true), vm)?; + return Ok(full); + } + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(handle), vm)?; + // `entity_type()` is the DXF name (for example `HATCH`), while the + // coverage catalog uses the Rust `EntityType` variant (`Hatch`). + let kind = ocs_plugin_api::entity_coverage::entity_snapshot(&entity) + .ok() + .and_then(|value| { + value + .as_object() + .and_then(|object| object.keys().next().cloned()) + }) + .ok_or_else(|| { + vm.new_runtime_error("ocs: cannot identify entity variant".to_owned()) + })?; + dict.set_item("kind", vm.new_pyobj(kind), vm)?; + dict.set_item("layer", vm.new_pyobj(entity.common().layer.clone()), vm)?; + dict.set_item( + "owner_handle", + vm.new_pyobj(entity.common().owner_handle.value()), + vm, + )?; + dict.set_item("_editable", vm.new_pyobj(false), vm)?; + Ok(dict.into()) + } + + #[cfg(feature = "experimental-host-model")] + fn snapshot_value_to_py( + value: serde_json::Value, + vm: &VirtualMachine, + ) -> PyResult { + Ok(match value { + serde_json::Value::Null => vm.ctx.none(), + serde_json::Value::Bool(value) => vm.new_pyobj(value), + serde_json::Value::Number(value) => { + if let Some(value) = value.as_i64() { + vm.new_pyobj(value) + } else if let Some(value) = value.as_u64() { + vm.new_pyobj(value) + } else { + vm.new_pyobj(value.as_f64().expect("JSON number")) + } + } + serde_json::Value::String(value) => vm.new_pyobj(value), + serde_json::Value::Array(values) => vm + .ctx + .new_list( + values + .into_iter() + .map(|value| snapshot_value_to_py(value, vm)) + .collect::>>()?, + ) + .into(), + serde_json::Value::Object(values) => { + let dict = vm.ctx.new_dict(); + for (key, value) in values { + dict.set_item(&key, snapshot_value_to_py(value, vm)?, vm)?; + } + dict.into() + } + }) + } + + /// Copy every serializable field of an entity, including kinds outside + /// the editable Python schema. The result is detached and read-only with + /// respect to the drawing; mutations to the returned dict do not commit. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_snapshot(handle: u64, vm: &VirtualMachine) -> PyResult { + let found = + host_ctx::with_host(|host| host.document().get_entity(Handle::new(handle)).cloned()) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })?; + let Some(entity) = found else { + return Ok(vm.ctx.none()); + }; + let value = ocs_plugin_api::entity_coverage::entity_snapshot(&entity).map_err(|error| { + vm.new_runtime_error(format!("ocs: entity snapshot failed: {error}")) + })?; + snapshot_value_to_py(value, vm) + } + + /// Host-owned coverage for a variant. `unmapped` properties exist in the + /// typed snapshot but have no Python document-model getter or setter. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_coverage(kind: String, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::entity_coverage::{EntityCoverageCatalog, EntityScope, ModelAccess}; + let catalog: EntityCoverageCatalog = + serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json()) + .map_err(|error| vm.new_runtime_error(error.to_string()))?; + let Some(entry) = catalog + .entity_kinds + .into_iter() + .find(|entry| entry.kind == kind) + else { + return Ok(vm.ctx.none()); + }; + let row = vm.ctx.new_dict(); + row.set_item("kind", vm.new_pyobj(entry.kind), vm)?; + let scope = match entry.scope { + EntityScope::Canvas => "canvas", + EntityScope::Internal => "internal", + EntityScope::Opaque => "opaque", + }; + row.set_item("scope", vm.new_pyobj(scope), vm)?; + let mut readable = Vec::new(); + let mut editable = Vec::new(); + let mut unmapped = Vec::new(); + let mut properties = Vec::new(); + for property in entry.properties { + let access = match property.model_access { + ModelAccess::ReadWrite => { + readable.push(property.name.clone()); + editable.push(property.name.clone()); + "read_write" + } + ModelAccess::ReadOnly => { + readable.push(property.name.clone()); + "read_only" + } + ModelAccess::Unmapped => { + unmapped.push(property.name.clone()); + "unmapped" + } + }; + let item = vm.ctx.new_dict(); + item.set_item("name", vm.new_pyobj(property.name), vm)?; + item.set_item("source_path", vm.new_pyobj(property.source_path), vm)?; + item.set_item("type", vm.new_pyobj(property.type_id), vm)?; + item.set_item("optional", vm.new_pyobj(property.optional), vm)?; + item.set_item("sequence", vm.new_pyobj(property.is_sequence), vm)?; + item.set_item( + "snapshot_readable", + vm.new_pyobj(property.snapshot_readable), + vm, + )?; + item.set_item("access", vm.new_pyobj(access), vm)?; + item.set_item("validation", vm.new_pyobj(property.validation), vm)?; + properties.push(item.into()); + } + let list = |values: Vec| { + vm.ctx.new_list( + values + .into_iter() + .map(|value| vm.new_pyobj(value)) + .collect(), + ) + }; + row.set_item("readable", list(readable).into(), vm)?; + row.set_item("editable", list(editable).into(), vm)?; + row.set_item("unmapped", list(unmapped).into(), vm)?; + row.set_item("properties", vm.ctx.new_list(properties).into(), vm)?; + Ok(row.into()) + } + + /// Enumerate every kind in the host-owned coverage catalog. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_kinds(vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::entity_coverage::EntityCoverageCatalog; + let catalog: EntityCoverageCatalog = + serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json()) + .map_err(|error| vm.new_runtime_error(error.to_string()))?; + Ok(vm + .ctx + .new_list( + catalog + .entity_kinds + .into_iter() + .map(|entry| vm.new_pyobj(entry.kind)) + .collect(), + ) + .into()) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn entity_handles(vm: &VirtualMachine) -> PyResult { + let handles = host_ctx::with_host(|host| { + host.document() + .entities() + .map(|entity| entity.common().handle.value()) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + Ok(vm + .ctx + .new_list( + handles + .into_iter() + .map(|handle| vm.new_pyobj(handle)) + .collect(), + ) + .into()) + } + + /// Add a new entity from a dict with a `"kind"` key (see `entity_to_dict` + /// for the shape `get_entity`/this function share) — a generic + /// alternative to `add_line`/`add_circle`/etc. covering every entity kind + /// the type registry currently traces. Returns the new handle. Errors if + /// `entity` is missing the geometry that actually defines the shape (a + /// line's `start`/`end`, a circle's `center`/`radius`, ...) rather than + /// silently defaulting it to zero. + #[pyfunction] + fn add(entity: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dict = entity.try_into_value::(vm)?; + let built = dict_to_entity(&dict, vm)?; + #[cfg(feature = "experimental-host-model")] + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&built) + .map_err(|error| vm.new_value_error(error))?; + host_ctx::with_host(|host| -> PyResult { + #[allow(unused_mut)] + let mut built = built; + #[cfg(feature = "experimental-host-model")] + { + ocs_plugin_api::entity_coverage::bind_canvas_entity_references( + host.document(), + &mut built, + ) + .map_err(|error| vm.new_value_error(error))?; + } + host_ctx::ensure_undo_started(host); + Ok(host.add_entity(built).value()) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + } + + /// Update `handle` by merging `entity`'s keys onto its *current* value — + /// a key `entity` doesn't mention is left exactly as it was, not reset to + /// a type default (Felix's Phase 1 review: reconstructing from a partial + /// dict could silently drop properties this generic API can't represent + /// at all, e.g. color/line-weight/XDATA). Errors if `handle` doesn't + /// exist or isn't a supported kind. + #[pyfunction] + fn update(handle: u64, entity: PyObjectRef, vm: &VirtualMachine) -> PyResult<()> { + let dict = entity.try_into_value::(vm)?; + let changed = host_ctx::with_host(|host| -> PyResult { + let Some(existing) = host.document().get_entity(Handle::new(handle)).cloned() else { + return Ok(false); + }; + #[allow(unused_mut)] + let mut updated = apply_dict_to_entity(&existing, &dict, vm)?; + #[cfg(feature = "experimental-host-model")] + { + ocs_plugin_api::entity_coverage::bind_canvas_entity_references( + host.document(), + &mut updated, + ) + .map_err(|error| vm.new_value_error(error))?; + ocs_plugin_api::entity_coverage::validate_entity_mutation(&existing, &updated) + .map_err(|error| vm.new_value_error(error))?; + } + host_ctx::ensure_undo_started(host); + Ok(host.update_entity(updated)) + }) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })??; + if !changed { + return Err(vm.new_runtime_error(format!("ocs.update: entity {handle} does not exist"))); + } + Ok(()) + } + + /// API v7: merge a list of property dictionaries and commit one undo step. + /// The host validates the entire batch before changing the drawing. + #[cfg(feature = "experimental-host-model")] + fn apply_layer_only_patch( + existing: &EntityType, + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, + ) -> PyResult { + if dict.keys_vec().len() != 2 || dict.get_item_opt("layer", vm)?.is_none() { + return Err(vm.new_value_error( + "ocs.update_many: this kind supports only a layer patch".to_owned(), + )); + } + let layer = dict.get_item("layer", vm)?.try_into_value::(vm)?; + ocs_plugin_api::entity_coverage::patch_canvas_layer(existing, &layer) + .map_err(|error| vm.new_value_error(format!("ocs.update_many: {error}"))) + } + + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn update_many(label: String, updates: Vec, vm: &VirtualMachine) -> PyResult<()> { + let result = host_ctx::with_host(|host| -> PyResult> { + let mut entities = Vec::with_capacity(updates.len()); + for patch in updates { + let dict = patch.try_into_value::(vm)?; + let handle = dict.get_item("handle", vm)?.try_into_value::(vm)?; + let existing = host + .document() + .get_entity(Handle::new(handle)) + .cloned() + .ok_or_else(|| { + vm.new_runtime_error(format!("entity {handle} does not exist")) + })?; + let changed = if entity_to_dict(vm, &existing).is_ok() { + apply_dict_to_entity(&existing, &dict, vm)? + } else { + apply_layer_only_patch(&existing, &dict, vm)? + }; + entities.push(changed); + } + Ok(host.update_entities_transaction(&label, entities)) + }) + .ok_or_else(|| { + vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()) + })??; + result.map_err(|error| vm.new_runtime_error(format!("ocs.update_many: {error}"))) + } + + #[cfg(test)] + mod entity_crud_tests { + use super::*; + #[cfg(feature = "experimental-host-model")] + use rustpython_vm::AsObject; + + #[cfg(feature = "experimental-host-model")] + #[test] + fn snapshot_conversion_preserves_nested_values() { + with_vm(|vm| { + let source = serde_json::json!({"Hatch": {"common": {"layer": "0"}, + "angles": [1.5, 2.0], "optional": null, "enabled": true}}); + let converted = snapshot_value_to_py(source, vm).unwrap(); + let dict = converted + .try_into_value::(vm) + .unwrap(); + let hatch = dict + .get_item("Hatch", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(); + let common = hatch + .get_item("common", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + common + .get_item("layer", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "0" + ); + assert!(hatch.get_item("optional", vm).unwrap().is(&vm.ctx.none())); + assert!(hatch + .get_item("enabled", vm) + .unwrap() + .try_into_value::(vm) + .unwrap()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn unmapped_canvas_kind_accepts_only_layer_patch() { + with_vm(|vm| { + let entity = EntityType::MLine(acadrust::entities::MLine::default()); + let patch = vm.ctx.new_dict(); + patch.set_item("handle", vm.new_pyobj(1_u64), vm).unwrap(); + patch + .set_item("layer", vm.new_pyobj("HATCHES"), vm) + .unwrap(); + let changed = apply_layer_only_patch(&entity, &patch, vm).unwrap(); + assert_eq!(changed.common().layer, "HATCHES"); + patch.set_item("scale", vm.new_pyobj(2.0), vm).unwrap(); + assert!(apply_layer_only_patch(&entity, &patch, vm).is_err()); + }); + } + + fn with_vm(f: impl FnOnce(&VirtualMachine) -> R) -> R { + let interp = rustpython_vm::Interpreter::without_stdlib(Default::default()); + interp.enter(f) + } + + fn set_vector3( + vm: &VirtualMachine, + dict: &rustpython_vm::builtins::PyDictRef, + key: &str, + x: f64, + y: f64, + z: f64, + ) { + let v = vm.ctx.new_dict(); + v.set_item("x", vm.new_pyobj(x), vm).unwrap(); + v.set_item("y", vm.new_pyobj(y), vm).unwrap(); + v.set_item("z", vm.new_pyobj(z), vm).unwrap(); + dict.set_item(key, v.into(), vm).unwrap(); + } + + #[cfg(feature = "experimental-host-model")] + fn vector2(vm: &VirtualMachine, x: f64, y: f64) -> PyObjectRef { + let value = vm.ctx.new_dict(); + value.set_item("x", vm.new_pyobj(x), vm).unwrap(); + value.set_item("y", vm.new_pyobj(y), vm).unwrap(); + value.into() + } + + #[cfg(feature = "experimental-host-model")] + fn hatch_boundary(vm: &VirtualMachine, size: f64) -> PyObjectRef { + let edges = [ + (0.0, 0.0, size, 0.0), + (size, 0.0, size, size), + (size, size, 0.0, size), + (0.0, size, 0.0, 0.0), + ] + .into_iter() + .map(|(x1, y1, x2, y2)| { + let line = vm.ctx.new_dict(); + line.set_item("start", vector2(vm, x1, y1), vm).unwrap(); + line.set_item("end", vector2(vm, x2, y2), vm).unwrap(); + let edge = vm.ctx.new_dict(); + edge.set_item("kind", vm.new_pyobj("Line"), vm).unwrap(); + edge.set_item("value", line.into(), vm).unwrap(); + edge.into() + }) + .collect(); + let path = vm.ctx.new_dict(); + path.set_item("flags", vm.new_pyobj(1_i64), vm).unwrap(); + path.set_item("edges", vm.ctx.new_list(edges).into(), vm) + .unwrap(); + path.set_item("boundary_handles", vm.ctx.new_list(Vec::new()).into(), vm) + .unwrap(); + path.into() + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn phase_seven_kinds_create_and_preserve_unmentioned_properties() { + with_vm(|vm| { + for kind in ["Ray", "XLine", "Solid", "Face3D"] { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj(kind), vm).unwrap(); + input + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + match kind { + "Ray" | "XLine" => { + set_vector3(vm, &input, "base_point", 1.0, 2.0, 0.0); + set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0); + } + _ => { + for (name, x, y) in [ + ("first_corner", 0.0, 0.0), + ("second_corner", 1.0, 0.0), + ("third_corner", 0.0, 1.0), + ("fourth_corner", 1.0, 1.0), + ] { + set_vector3(vm, &input, name, x, y, 0.0); + } + } + } + if kind == "Solid" { + input.set_item("is_trace", vm.new_pyobj(true), vm).unwrap(); + } + if kind == "Face3D" { + input + .set_item("invisible_edges", vm.new_pyobj(5_i64), vm) + .unwrap(); + } + let original = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap(); + let rendered = entity_to_dict(vm, &original) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("kind", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + kind + ); + let patch = vm.ctx.new_dict(); + let changed_field = if matches!(kind, "Ray" | "XLine") { + "base_point" + } else { + "first_corner" + }; + set_vector3(vm, &patch, changed_field, 7.0, 8.0, 0.0); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &updated) + .unwrap(); + let updated_dict = entity_to_dict(vm, &updated) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + updated_dict + .get_item("layer", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Geometry" + ); + if kind == "Solid" { + assert!(updated_dict + .get_item("is_trace", vm) + .unwrap() + .try_into_value::(vm) + .unwrap()); + } + if kind == "Face3D" { + assert_eq!( + updated_dict + .get_item("invisible_edges", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 5 + ); + } + } + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn face3d_flags_reject_integer_overflow() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj("Face3D"), vm).unwrap(); + for name in [ + "first_corner", + "second_corner", + "third_corner", + "fourth_corner", + ] { + set_vector3(vm, &input, name, 0.0, 0.0, 0.0); + } + input + .set_item("invisible_edges", vm.new_pyobj(256_i64), vm) + .unwrap(); + assert!(dict_to_entity(&input, vm).is_err()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn insert_is_update_only_and_preserves_block_identity() { + with_vm(|vm| { + let mut insert = + acadrust::entities::Insert::new("DOOR", Vector3::new(1.0, 2.0, 0.0)); + insert.set_x_scale(2.0); + let original = EntityType::Insert(insert); + let rendered = entity_to_dict(vm, &original) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("block_name", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "DOOR" + ); + assert_eq!( + rendered + .get_item("x_scale", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 2.0 + ); + assert!(dict_to_entity(&rendered, vm).is_err()); + + let patch = vm.ctx.new_dict(); + set_vector3(vm, &patch, "insert_point", 4.0, 5.0, 0.0); + patch.set_item("y_scale", vm.new_pyobj(3.0), vm).unwrap(); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &updated) + .unwrap(); + let EntityType::Insert(updated) = updated else { + panic!("expected Insert"); + }; + assert_eq!(updated.block_name, "DOOR"); + assert_eq!(updated.insert_point, Vector3::new(4.0, 5.0, 0.0)); + assert_eq!(updated.x_scale(), 2.0); + assert_eq!(updated.y_scale(), 3.0); + + patch + .set_item("block_name", vm.new_pyobj("OTHER"), vm) + .unwrap(); + assert!(apply_dict_to_entity(&original, &patch, vm).is_err()); + patch.del_item("block_name", vm).unwrap(); + patch.set_item("z_scale", vm.new_pyobj(0.0), vm).unwrap(); + assert!(apply_dict_to_entity(&original, &patch, vm).is_err()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn tolerance_converts_and_style_change_clears_stale_handle() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input + .set_item("kind", vm.new_pyobj("Tolerance"), vm) + .unwrap(); + set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0); + set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0); + input + .set_item("text", vm.new_pyobj("{\\Fgdt;p}%%v0.1"), vm) + .unwrap(); + input + .set_item("dimension_style_name", vm.new_pyobj("Standard"), vm) + .unwrap(); + let original = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap(); + let EntityType::Tolerance(mut with_handle) = original.clone() else { + panic!("expected Tolerance"); + }; + with_handle.dimension_style_handle = Some(Handle::new(42)); + let original = EntityType::Tolerance(with_handle); + let patch = vm.ctx.new_dict(); + patch + .set_item("text", vm.new_pyobj("{\\Fgdt;p}%%v0.2"), vm) + .unwrap(); + patch + .set_item("dimension_style_name", vm.new_pyobj("ISO-25"), vm) + .unwrap(); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + let EntityType::Tolerance(updated) = updated else { + panic!("expected Tolerance"); + }; + assert_eq!(updated.text, "{\\Fgdt;p}%%v0.2"); + assert_eq!(updated.dimension_style_name, "ISO-25"); + assert_eq!(updated.dimension_style_handle, None); + assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0)); + let rendered = entity_to_dict(vm, &EntityType::Tolerance(updated)) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert!(rendered + .get_item_opt("dimension_style_handle", vm) + .unwrap() + .is_some()); + assert!(rendered + .get_item_opt("dwg_unknown_short", vm) + .unwrap() + .is_none()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn shape_converts_and_style_change_clears_stale_handle() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj("Shape"), vm).unwrap(); + set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0); + input.set_item("size", vm.new_pyobj(2.0), vm).unwrap(); + input + .set_item("shape_name", vm.new_pyobj("ARROW"), vm) + .unwrap(); + input + .set_item("shape_number", vm.new_pyobj(1_i32), vm) + .unwrap(); + input + .set_item("style_name", vm.new_pyobj("Symbols"), vm) + .unwrap(); + let original = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&original).unwrap(); + let EntityType::Shape(mut with_handle) = original.clone() else { + panic!("expected Shape"); + }; + with_handle.style_handle = Some(Handle::new(42)); + let original = EntityType::Shape(with_handle); + let patch = vm.ctx.new_dict(); + patch.set_item("size", vm.new_pyobj(3.0), vm).unwrap(); + patch + .set_item("style_name", vm.new_pyobj("OtherSymbols"), vm) + .unwrap(); + let updated = apply_dict_to_entity(&original, &patch, vm).unwrap(); + let EntityType::Shape(updated) = updated else { + panic!("expected Shape"); + }; + assert_eq!(updated.size, 3.0); + assert_eq!(updated.shape_name, "ARROW"); + assert_eq!(updated.shape_number, 1); + assert_eq!(updated.style_name, "OtherSymbols"); + assert_eq!(updated.style_handle, None); + assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0)); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn attribute_definition_converts_owner_and_preserves_embedded_mtext() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input + .set_item("kind", vm.new_pyobj("AttributeDefinition"), vm) + .unwrap(); + input + .set_item("owner_handle", vm.new_pyobj(42_u64), vm) + .unwrap(); + input.set_item("tag", vm.new_pyobj("PART_NO"), vm).unwrap(); + input + .set_item("prompt", vm.new_pyobj("Part number"), vm) + .unwrap(); + input + .set_item("default_value", vm.new_pyobj("PN-001"), vm) + .unwrap(); + set_vector3(vm, &input, "insertion_point", 1.0, 2.0, 0.0); + input.set_item("height", vm.new_pyobj(2.5), vm).unwrap(); + let entity = dict_to_entity(&input, vm).unwrap(); + let EntityType::AttributeDefinition(mut definition) = entity else { + panic!("expected AttributeDefinition"); + }; + assert_eq!(definition.common.owner_handle, Handle::new(42)); + assert_eq!(definition.tag, "PART_NO"); + assert_eq!(definition.text_style, "Standard"); + + let mut embedded = acadrust::entities::MText::default(); + embedded.value = "first\\Psecond".into(); + definition.embedded_mtext = Some(Box::new(embedded)); + let entity = EntityType::AttributeDefinition(definition); + let patch = vm.ctx.new_dict(); + patch + .set_item("default_value", vm.new_pyobj("PN-002"), vm) + .unwrap(); + set_vector3(vm, &patch, "insertion_point", 4.0, 5.0, 0.0); + let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + let EntityType::AttributeDefinition(updated) = updated else { + panic!("expected AttributeDefinition"); + }; + assert_eq!(updated.default_value, "PN-002"); + assert_eq!(updated.insertion_point, Vector3::new(4.0, 5.0, 0.0)); + assert_eq!( + updated.embedded_mtext.as_ref().unwrap().value, + "first\\Psecond" + ); + + let rendered = entity_to_dict(vm, &EntityType::AttributeDefinition(updated)) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("owner_handle", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 42 + ); + assert!(rendered + .get_item_opt("embedded_mtext", vm) + .unwrap() + .is_none()); + + let owner_patch = vm.ctx.new_dict(); + owner_patch + .set_item("owner_handle", vm.new_pyobj(43_u64), vm) + .unwrap(); + assert!(apply_dict_to_entity(&entity, &owner_patch, vm) + .unwrap_err() + .args() + .as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap() + .contains("read-only after creation")); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn attribute_entity_converts_nested_identity_and_preserves_embedded_mtext() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input + .set_item("kind", vm.new_pyobj("AttributeEntity"), vm) + .unwrap(); + input + .set_item("owner_handle", vm.new_pyobj(50_u64), vm) + .unwrap(); + input.set_item("tag", vm.new_pyobj("PART_NO"), vm).unwrap(); + input.set_item("value", vm.new_pyobj("PN-101"), vm).unwrap(); + set_vector3(vm, &input, "insertion_point", 10.0, 2.0, 0.0); + input.set_item("height", vm.new_pyobj(2.5), vm).unwrap(); + let entity = dict_to_entity(&input, vm).unwrap(); + let EntityType::AttributeEntity(mut attribute) = entity else { + panic!("expected AttributeEntity"); + }; + assert_eq!(attribute.common.owner_handle, Handle::new(50)); + attribute.attdef_handle = Handle::new(42); + let mut embedded = acadrust::entities::MText::default(); + embedded.value = "first\\Psecond".into(); + attribute.embedded_mtext = Some(Box::new(embedded)); + let entity = EntityType::AttributeEntity(attribute); + + let patch = vm.ctx.new_dict(); + patch.set_item("value", vm.new_pyobj("PN-102"), vm).unwrap(); + set_vector3(vm, &patch, "insertion_point", 12.0, 3.0, 0.0); + let updated_entity = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + let EntityType::AttributeEntity(updated) = &updated_entity else { + unreachable!() + }; + assert_eq!(updated.value, "PN-102"); + assert_eq!(updated.attdef_handle, Handle::new(42)); + assert_eq!( + updated.embedded_mtext.as_ref().unwrap().value, + "first\\Psecond" + ); + + let tag_patch = vm.ctx.new_dict(); + tag_patch + .set_item("tag", vm.new_pyobj("SERIAL"), vm) + .unwrap(); + let retagged = apply_dict_to_entity(&updated_entity, &tag_patch, vm).unwrap(); + assert!(matches!(retagged, EntityType::AttributeEntity(value) + if value.tag == "SERIAL" && value.attdef_handle == Handle::new(42))); + let rendered = entity_to_dict(vm, &updated_entity) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("attdef_handle", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 42 + ); + assert!(rendered + .get_item_opt("embedded_mtext", vm) + .unwrap() + .is_none()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn hatch_converts_tagged_boundaries_patterns_and_preserves_gradient() { + with_vm(|vm| { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj("Hatch"), vm).unwrap(); + input + .set_item( + "paths", + vm.ctx.new_list(vec![hatch_boundary(vm, 10.0)]).into(), + vm, + ) + .unwrap(); + let entity = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&entity).unwrap(); + let EntityType::Hatch(mut hatch) = entity else { + panic!("expected Hatch") + }; + hatch.gradient_color.enabled = true; + hatch.gradient_color.name = "LINEAR".into(); + let entity = EntityType::Hatch(hatch); + + let pattern_line = vm.ctx.new_dict(); + pattern_line + .set_item("angle", vm.new_pyobj(0.0), vm) + .unwrap(); + pattern_line + .set_item("base_point", vector2(vm, 0.0, 0.0), vm) + .unwrap(); + pattern_line + .set_item("offset", vector2(vm, 0.0, 2.0), vm) + .unwrap(); + pattern_line + .set_item( + "dash_lengths", + vm.ctx + .new_list(vec![vm.new_pyobj(1.0), vm.new_pyobj(-1.0)]) + .into(), + vm, + ) + .unwrap(); + let pattern = vm.ctx.new_dict(); + pattern.set_item("name", vm.new_pyobj("TEST"), vm).unwrap(); + pattern + .set_item("description", vm.new_pyobj("test pattern"), vm) + .unwrap(); + pattern + .set_item( + "lines", + vm.ctx.new_list(vec![pattern_line.into()]).into(), + vm, + ) + .unwrap(); + let patch = vm.ctx.new_dict(); + patch.set_item("is_solid", vm.new_pyobj(false), vm).unwrap(); + patch.set_item("pattern", pattern.into(), vm).unwrap(); + patch + .set_item("pattern_scale", vm.new_pyobj(2.0), vm) + .unwrap(); + patch + .set_item( + "paths", + vm.ctx.new_list(vec![hatch_boundary(vm, 12.0)]).into(), + vm, + ) + .unwrap(); + let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&entity, &updated) + .unwrap(); + let EntityType::Hatch(updated) = updated else { + unreachable!() + }; + assert!(!updated.is_solid); + assert_eq!(updated.pattern.name, "TEST"); + assert_eq!(updated.pattern.lines.len(), 1); + assert_eq!(updated.paths[0].edges.len(), 4); + assert!(updated.gradient_color.enabled); + let rendered = entity_to_dict(vm, &EntityType::Hatch(updated)) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert!(rendered + .get_item_opt("gradient_color", vm) + .unwrap() + .is_none()); + assert_eq!( + rendered + .get_item("paths", vm) + .unwrap() + .try_into_value::>(vm) + .unwrap() + .len(), + 1 + ); + let forbidden = vm.ctx.new_dict(); + forbidden + .set_item("gradient_color", vm.ctx.new_dict().into(), vm) + .unwrap(); + assert!(apply_dict_to_entity(&entity, &forbidden, vm).is_err()); + }); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn phase_seven_kinds_survive_dwg_round_trip() { + with_vm(|vm| { + let mut doc = acadrust::CadDocument::new(); + let mut handles = Vec::new(); + for kind in ["Ray", "XLine", "Solid", "Face3D"] { + let input = vm.ctx.new_dict(); + input.set_item("kind", vm.new_pyobj(kind), vm).unwrap(); + if matches!(kind, "Ray" | "XLine") { + set_vector3(vm, &input, "base_point", 2.0, 3.0, 0.0); + set_vector3(vm, &input, "direction", 1.0, 0.0, 0.0); + } else { + for (name, x, y) in [ + ("first_corner", 0.0, 0.0), + ("second_corner", 1.0, 0.0), + ("third_corner", 0.0, 1.0), + ("fourth_corner", 1.0, 1.0), + ] { + set_vector3(vm, &input, name, x, y, 0.0); + } + } + let entity = dict_to_entity(&input, vm).unwrap(); + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&entity).unwrap(); + handles.push((kind, doc.add_entity(entity).unwrap())); + } + let path = std::env::temp_dir() + .join(format!("ocs_phase_seven_{}.dwg", std::process::id())); + acadrust::DwgWriter::write_to_file(&path, &doc).unwrap(); + let reopened = acadrust::DwgReader::from_file(&path) + .unwrap() + .read() + .unwrap(); + let _ = std::fs::remove_file(&path); + for (kind, handle) in handles { + let entity = reopened.get_entity(handle).expect("entity survives reopen"); + let rendered = entity_to_dict(vm, entity) + .unwrap() + .try_into_value::(vm) + .unwrap(); + assert_eq!( + rendered + .get_item("kind", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + kind + ); + } + }); + } + + #[test] + fn text_is_in_editable_schema_and_keeps_unmentioned_fields() { + with_vm(|vm| { + let mut text = acadrust::entities::Text::new(); + text.value = "Before".into(); + text.height = 3.5; + text.insertion_point = Vector3::new(1.0, 2.0, 0.0); + let entity = EntityType::Text(text); + let data = entity_to_dict(vm, &entity).unwrap(); + let data = data + .try_into_value::(vm) + .unwrap(); + assert_eq!( + data.get_item("text", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Before" + ); + let patch = vm.ctx.new_dict(); + patch.set_item("text", vm.new_pyobj("After"), vm).unwrap(); + let updated = apply_dict_to_entity(&entity, &patch, vm).unwrap(); + let EntityType::Text(updated) = updated else { + panic!("expected text"); + }; + assert_eq!(updated.value, "After"); + assert_eq!(updated.height, 3.5); + assert_eq!(updated.insertion_point, Vector3::new(1.0, 2.0, 0.0)); + }); + } + + #[test] + fn circle_round_trips_through_dict_to_entity_and_back() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Circle"), vm).unwrap(); + dict.set_item("layer", vm.new_pyobj("Walls"), vm).unwrap(); + set_vector3(vm, &dict, "center", 1.0, 2.0, 0.0); + dict.set_item("radius", vm.new_pyobj(5.0), vm).unwrap(); + + let entity = dict_to_entity(&dict, vm).expect("dict_to_entity"); + let acadrust::EntityType::Circle(circle) = &entity else { + panic!("expected Circle, got {entity:?}"); + }; + assert_eq!(circle.common.layer, "Walls"); + assert_eq!(circle.center, Vector3::new(1.0, 2.0, 0.0)); + assert_eq!(circle.radius, 5.0); + + let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict"); + let round_tripped = round_tripped + .try_into_value::(vm) + .unwrap(); + assert_eq!( + round_tripped + .get_item("kind", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Circle" + ); + assert_eq!( + round_tripped + .get_item("radius", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + 5.0 + ); + }); + } + + #[test] + fn add_rejects_missing_required_geometry_instead_of_defaulting_to_zero() { + with_vm(|vm| { + // Felix's Phase 1 review: a Circle with no center/radius must + // error, not silently become a zero-radius circle at the origin. + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Circle"), vm).unwrap(); + let err = dict_to_entity(&dict, vm).expect_err("missing radius/center must error"); + let message = err.args().as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap(); + assert!( + message.contains("requires"), + "unexpected message: {message}" + ); + + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + let err = dict_to_entity(&dict, vm).expect_err("missing vertices must error"); + let message = err.args().as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap(); + assert!( + message.contains("at least one"), + "unexpected message: {message}" + ); + + // An explicitly empty vertex list is exactly as invalid as an + // absent one — both parse to the same empty Vec. + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + dict.set_item("vertices", vm.ctx.new_list(vec![]).into(), vm) + .unwrap(); + assert!(dict_to_entity(&dict, vm).is_err()); + }); + } + + #[test] + fn polyline_flags_override_exposes_closed_as_a_plain_bool() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + dict.set_item("closed", vm.new_pyobj(true), vm).unwrap(); + let vertex = vm.ctx.new_dict(); + let location = vm.ctx.new_dict(); + location.set_item("x", vm.new_pyobj(0.0), vm).unwrap(); + location.set_item("y", vm.new_pyobj(0.0), vm).unwrap(); + location.set_item("z", vm.new_pyobj(0.0), vm).unwrap(); + vertex.set_item("location", location.into(), vm).unwrap(); + dict.set_item("vertices", vm.ctx.new_list(vec![vertex.into()]).into(), vm) + .unwrap(); + + let entity = dict_to_entity(&dict, vm).expect("dict_to_entity"); + let acadrust::EntityType::Polyline(polyline) = &entity else { + panic!("expected Polyline, got {entity:?}"); + }; + assert!(polyline.flags.is_closed()); + assert_eq!(polyline.vertices.len(), 1); + + let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict"); + let round_tripped = round_tripped + .try_into_value::(vm) + .unwrap(); + assert!(round_tripped + .get_item("closed", vm) + .unwrap() + .try_into_value::(vm) + .unwrap()); + }); + } + + #[test] + fn mtext_round_trips_unit_enum_and_excluded_fields() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("MText"), vm).unwrap(); + dict.set_item("text", vm.new_pyobj("hello"), vm).unwrap(); + set_vector3(vm, &dict, "insertion", 0.0, 0.0, 0.0); + dict.set_item("attachment_point", vm.new_pyobj("MiddleCenter"), vm) + .unwrap(); + + let entity = dict_to_entity(&dict, vm).expect("dict_to_entity"); + let acadrust::EntityType::MText(text) = &entity else { + panic!("expected MText, got {entity:?}"); + }; + assert_eq!(text.value, "hello"); + assert_eq!( + text.attachment_point, + acadrust::entities::AttachmentPoint::MiddleCenter + ); + + let round_tripped = entity_to_dict(vm, &entity).expect("entity_to_dict"); + let round_tripped = round_tripped + .try_into_value::(vm) + .unwrap(); + assert_eq!( + round_tripped + .get_item("attachment_point", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "MiddleCenter" + ); + assert!(round_tripped + .get_item_opt("background_color", vm) + .unwrap() + .is_none()); + }); + } + + #[test] + fn update_preserves_fields_the_dict_does_not_mention() { + with_vm(|vm| { + let add_dict = vm.ctx.new_dict(); + add_dict.set_item("kind", vm.new_pyobj("Line"), vm).unwrap(); + add_dict + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + set_vector3(vm, &add_dict, "start", 0.0, 0.0, 0.0); + set_vector3(vm, &add_dict, "end", 10.0, 0.0, 0.0); + let existing = dict_to_entity(&add_dict, vm).expect("dict_to_entity"); + + // Only "end" is mentioned — "start" and "layer" must survive + // untouched, unlike a fresh `dict_to_entity` from this same + // partial dict (which would reset them to their type defaults). + let update_dict = vm.ctx.new_dict(); + set_vector3(vm, &update_dict, "end", 20.0, 5.0, 0.0); + let updated = apply_dict_to_entity(&existing, &update_dict, vm) + .expect("apply_dict_to_entity"); + + let acadrust::EntityType::Line(line) = &updated else { + panic!("expected Line, got {updated:?}"); + }; + assert_eq!(line.common.layer, "Geometry", "layer must be preserved"); + assert_eq!( + line.start, + Vector3::new(0.0, 0.0, 0.0), + "start must be preserved" + ); + assert_eq!( + line.end, + Vector3::new(20.0, 5.0, 0.0), + "end must be updated" + ); + + // A second update, this time touching the layer, must not + // disturb the geometry it doesn't mention either. + let update_dict = vm.ctx.new_dict(); + update_dict + .set_item("layer", vm.new_pyobj("Renamed"), vm) + .unwrap(); + let updated = + apply_dict_to_entity(&updated, &update_dict, vm).expect("apply_dict_to_entity"); + let acadrust::EntityType::Line(line) = &updated else { + panic!("expected Line, got {updated:?}"); + }; + assert_eq!(line.common.layer, "Renamed"); + assert_eq!(line.start, Vector3::new(0.0, 0.0, 0.0)); + assert_eq!(line.end, Vector3::new(20.0, 5.0, 0.0)); + }); + } + + /// The concrete Phase 1 acceptance test Felix asked for: create, read, + /// edit, delete, save, and reopen a Line and an Ellipse, with common + /// properties preserved — exercised against a real + /// `acadrust::CadDocument` and a real DWG round trip (not a fake + /// `HostApi`; `dict_to_entity`/`apply_dict_to_entity`/`entity_to_dict` + /// are exactly what a live `HostApi`-backed `ocs.add`/`ocs.update`/ + /// `ocs.get_entity` call into, so this covers the same logic without + /// needing a from-scratch test double for the whole `HostApi` trait). + #[test] + fn line_and_ellipse_survive_create_read_edit_delete_save_reopen() { + with_vm(|vm| { + let mut doc = acadrust::CadDocument::new(); + // A layer must exist in the document's layer table before an + // entity can reference it — same requirement `set_entity_layer` + // above already enforces for a live document. Without this, + // the DWG writer silently falls back the entity to layer "0" + // instead of erroring, which this test caught the first time + // around (it used "Geometry" without registering it first). + doc.layers.add(acadrust::Layer::new("Geometry")).unwrap(); + + // --- CREATE --- + let add_line = vm.ctx.new_dict(); + add_line.set_item("kind", vm.new_pyobj("Line"), vm).unwrap(); + add_line + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + set_vector3(vm, &add_line, "start", 0.0, 0.0, 0.0); + set_vector3(vm, &add_line, "end", 10.0, 5.0, 0.0); + let line_handle = doc + .add_entity(dict_to_entity(&add_line, vm).expect("dict_to_entity Line")) + .expect("doc.add_entity Line"); + + let add_ellipse = vm.ctx.new_dict(); + add_ellipse + .set_item("kind", vm.new_pyobj("Ellipse"), vm) + .unwrap(); + add_ellipse + .set_item("layer", vm.new_pyobj("Geometry"), vm) + .unwrap(); + set_vector3(vm, &add_ellipse, "center", 1.0, 2.0, 0.0); + set_vector3(vm, &add_ellipse, "major_axis", 5.0, 0.0, 0.0); + add_ellipse + .set_item("minor_axis_ratio", vm.new_pyobj(0.5), vm) + .unwrap(); + let ellipse_handle = doc + .add_entity(dict_to_entity(&add_ellipse, vm).expect("dict_to_entity Ellipse")) + .expect("doc.add_entity Ellipse"); + + // --- READ --- + let line_dict = + entity_to_dict(vm, doc.get_entity(line_handle).expect("line exists")) + .expect("entity_to_dict Line"); + assert_eq!( + line_dict + .clone() + .try_into_value::(vm) + .unwrap() + .get_item("layer", vm) + .unwrap() + .try_into_value::(vm) + .unwrap(), + "Geometry" + ); + + // --- EDIT (update-merge: only "end" changes) --- + let existing = doc.get_entity(line_handle).unwrap().clone(); + let edit_dict = vm.ctx.new_dict(); + set_vector3(vm, &edit_dict, "end", 20.0, 5.0, 0.0); + let updated = apply_dict_to_entity(&existing, &edit_dict, vm) + .expect("apply_dict_to_entity Line"); + *doc.get_entity_mut(line_handle).unwrap() = updated; + + let acadrust::EntityType::Line(line) = doc.get_entity(line_handle).unwrap() else { + panic!("expected Line"); + }; + assert_eq!( + line.common.layer, "Geometry", + "layer survives an unrelated edit" + ); + assert_eq!( + line.start, + Vector3::new(0.0, 0.0, 0.0), + "start survives an unrelated edit" + ); + assert_eq!( + line.end, + Vector3::new(20.0, 5.0, 0.0), + "end reflects the edit" + ); + + // --- DELETE --- + assert!(doc.remove_entity(ellipse_handle).is_some()); + assert!(doc.get_entity(ellipse_handle).is_none()); + + // --- SAVE / REOPEN --- + let path = std::env::temp_dir().join(format!( + "ocs_entity_crud_acceptance_test_{}.dwg", + std::process::id() + )); + acadrust::DwgWriter::write_to_file(&path, &doc).expect("write dwg"); + let reopened = acadrust::DwgReader::from_file(&path) + .expect("open dwg") + .read() + .expect("read dwg"); + let _ = std::fs::remove_file(&path); + + let acadrust::EntityType::Line(reopened_line) = reopened + .get_entity(line_handle) + .expect("line survives save/reopen") + else { + panic!("expected Line"); + }; + assert_eq!( + reopened_line.common.layer, "Geometry", + "layer survives a real DWG round trip" + ); + assert_eq!( + reopened_line.start, + Vector3::new(0.0, 0.0, 0.0), + "start survives a real DWG round trip" + ); + assert_eq!( + reopened_line.end, + Vector3::new(20.0, 5.0, 0.0), + "the edit survives a real DWG round trip" + ); + assert!( + reopened.get_entity(ellipse_handle).is_none(), + "the deleted ellipse must not reappear after reopen" + ); + }); + } + } +} + +/// These run a real RustPython interpreter with the `ocs` module registered +/// (same setup as `lib.rs`'s `run_eval`), but with no `HostGuard` active — so +/// `host_ctx::with_host` always returns `None`. `add_points` and +/// `write_record` both finish all of their own argument/dict parsing +/// *before* calling `host_ctx::with_host`, so getting exactly the sentinel +/// "not running inside a PY_ command" `RuntimeError` (rather than a +/// `TypeError`/`ValueError` from the parsing itself) confirms the new +/// RustPython glue code — `Vec` extraction, `PyDictRef` +/// downcasting, `try_into_value` for every XDATA value kind — actually works +/// against the real interpreter, without needing a live `HostApi`. +#[cfg(test)] +mod tests { + use super::module_def; + + #[cfg(feature = "experimental-host-model")] + #[test] + fn host_coverage_matches_generated_python_keys() { + use ocs_plugin_api::entity_coverage::{EntityCoverageCatalog, ModelAccess}; + use std::collections::{BTreeMap, BTreeSet}; + + let catalog: EntityCoverageCatalog = + serde_json::from_str(ocs_plugin_api::get_embedded_entity_coverage_json()).unwrap(); + let generated = include_str!(concat!(env!("OUT_DIR"), "/entity_crud.rs")); + let converter = generated + .split("pub(crate) fn entity_to_dict") + .nth(1) + .unwrap() + .split("pub(crate) fn dict_to_entity") + .next() + .unwrap(); + let mut emitted: BTreeMap> = BTreeMap::new(); + let mut current = None; + for line in converter.lines() { + let line = line.trim(); + if let Some(arm) = line.strip_prefix("acadrust::EntityType::") { + if let Some((kind, _)) = arm.split_once("(value) => {") { + current = Some(kind.to_owned()); + emitted.entry(kind.to_owned()).or_default(); + } + } + if let (Some(kind), Some(key)) = (¤t, line.strip_prefix("dict.set_item(\"")) { + let key = key.split('"').next().unwrap(); + emitted.get_mut(kind).unwrap().insert(key.to_owned()); + } + if line == "}" { + current = None; + } + } + for entry in &catalog.entity_kinds { + let expected: BTreeSet<_> = entry + .properties + .iter() + .filter(|property| property.model_access != ModelAccess::Unmapped) + .map(|property| property.name.clone()) + .collect(); + if let Some(actual) = emitted.get(&entry.kind) { + assert_eq!(&expected, actual, "{}", entry.kind); + } else { + assert_eq!( + expected, + BTreeSet::from([ + "handle".to_owned(), + "kind".to_owned(), + "layer".to_owned(), + "owner_handle".to_owned() + ]), + "{}", + entry.kind + ); + } + } + assert_eq!(emitted.len(), 22); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn entity_snapshot_uses_catalog_variant_name() { + use ocs_plugin_api::host::acadrust; + let hatch = acadrust::EntityType::Hatch(acadrust::entities::Hatch::default()); + let value = serde_json::to_value(hatch).unwrap(); + let kind = value.as_object().unwrap().keys().next().unwrap(); + assert_eq!(kind, "Hatch"); + assert_ne!(kind, "HATCH"); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn python_can_query_host_coverage_without_document() { + assert_eq!(eval("len(ocs.entity_kinds())"), Ok("48".to_owned())); + assert_eq!( + eval("'paths' in ocs.entity_coverage('Hatch')['editable']"), + Ok("True".to_owned()) + ); + assert_eq!( + eval("ocs.entity_coverage('Line')['scope']"), + Ok("canvas".to_owned()) + ); + } + + #[cfg(feature = "experimental-host-model")] + #[test] + fn update_many_binding_parses_python_batch() { + let error = eval("ocs.update_many('Move', [{'handle': 1, 'layer': '0'}])").unwrap_err(); + assert!( + error.contains("not running inside a PY_ command"), + "{error}" + ); + } + + // Runs `source` (a hardcoded literal in each test below, never external + // input) as a single expression inside a fresh, stdlib-free RustPython + // interpreter with only the `ocs` module registered — the same sandboxed + // `Mode::Eval` setup `lib.rs`'s `run_eval` uses for real `PY_EVAL` + // commands. Not a generic "eval untrusted string" helper. + fn eval(source: &str) -> Result { + let builder = rustpython_vm::Interpreter::builder(Default::default()); + let ocs_def = module_def(&builder.ctx); + let interp = builder.add_native_module(ocs_def).build(); + interp.enter(|vm| { + let scope = vm.new_scope_with_builtins(); + let ocs = vm.import("ocs", 0).map_err(|exc| exc_string(vm, &exc))?; + scope + .globals + .set_item("ocs", ocs, vm) + .map_err(|exc| exc_string(vm, &exc))?; + let code_obj = vm + .compile( + source, + rustpython_vm::compiler::Mode::Eval, + "".to_string(), + ) + .map_err(|err| err.to_string())?; + match vm.run_code_obj(code_obj, scope) { + Ok(result) => result + .str(vm) + .map(|s| s.to_string()) + .map_err(|exc| exc_string(vm, &exc)), + Err(exc) => Err(exc_string(vm, &exc)), + } + }) + } + + fn exc_string( + vm: &rustpython_vm::VirtualMachine, + exc: &rustpython_vm::builtins::PyBaseExceptionRef, + ) -> String { + let obj: rustpython_vm::PyObjectRef = exc.clone().into(); + let name = obj.class().name().to_string(); + let message = obj.str(vm).map(|s| s.to_string()).unwrap_or_default(); + format!("{name}: {message}") + } + + const NO_HOST: &str = "RuntimeError: ocs: not running inside a PY_ command"; + + #[test] + fn add_points_parses_before_reaching_the_host() { + let result = eval("ocs.add_points([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])"); + assert_eq!(result, Err(NO_HOST.to_string())); + } + + #[test] + fn add_points_rejects_wrong_point_shape() { + let result = eval("ocs.add_points([[1.0, 2.0]])"); + assert!(matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add_points"))); + } + + #[test] + fn add_points_rejects_empty_list() { + let result = eval("ocs.add_points([])"); + assert!(matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add_points"))); + } + + #[test] + fn write_record_parses_every_xdata_kind_before_reaching_the_host() { + let result = eval( + r#"ocs.write_record(1, "TEST", [ + {"kind": "String", "value": "hi"}, + {"kind": "ControlString", "value": "{"}, + {"kind": "LayerName", "value": "0"}, + {"kind": "BinaryData", "value": b"\x01\x02"}, + {"kind": "Handle", "value": 7}, + {"kind": "Point3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Position3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Displacement3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Direction3D", "value": [1.0, 2.0, 3.0]}, + {"kind": "Real", "value": 1.5}, + {"kind": "Distance", "value": 2.5}, + {"kind": "ScaleFactor", "value": 1.0}, + {"kind": "Integer16", "value": 16}, + {"kind": "Integer32", "value": 32}, + ])"#, + ); + assert_eq!(result, Err(NO_HOST.to_string())); + } + + #[test] + fn write_record_rejects_unknown_kind() { + let result = eval(r#"ocs.write_record(1, "TEST", [{"kind": "Nope", "value": 1}])"#); + assert!(matches!( + result, + Err(ref msg) if msg.starts_with("ValueError: unsupported XDATA value kind: Nope") + )); + } + + #[test] + fn write_record_rejects_malformed_point_value() { + let result = + eval(r#"ocs.write_record(1, "TEST", [{"kind": "Point3D", "value": [1.0, 2.0]}])"#); + assert!(matches!( + result, + Err(ref msg) if msg.starts_with("ValueError: XDATA Point3D") + )); + } + + #[test] + fn read_record_and_remove_record_parse_before_reaching_the_host() { + assert_eq!( + eval(r#"ocs.read_record(1, "TEST")"#), + Err(NO_HOST.to_string()) + ); + assert_eq!( + eval(r#"ocs.remove_record(1, "TEST")"#), + Err(NO_HOST.to_string()) + ); + } + + #[test] + fn add_parses_before_reaching_the_host() { + let result = eval( + r#"ocs.add({"kind": "Line", "start": {"x": 0.0, "y": 0.0, "z": 0.0}, "end": {"x": 1.0, "y": 1.0, "z": 0.0}})"#, + ); + assert_eq!(result, Err(NO_HOST.to_string())); + } + + #[test] + fn add_rejects_missing_required_geometry_before_reaching_the_host() { + let result = eval(r#"ocs.add({"kind": "Circle"})"#); + assert!( + matches!(result, Err(ref msg) if msg.starts_with("ValueError: ocs.add: Circle requires")), + "unexpected result: {result:?}" + ); + } + + #[test] + fn add_rejects_unknown_kind() { + let result = eval(r#"ocs.add({"kind": "Nope"})"#); + assert!(matches!( + result, + Err(ref msg) if msg.starts_with("ValueError: ocs: unsupported entity kind: Nope") + )); + } + + #[test] + fn update_and_get_entity_hit_the_host_sentinel() { + assert_eq!( + eval(r#"ocs.update(1, {"layer": "X"})"#), + Err(NO_HOST.to_string()) + ); + assert_eq!(eval("ocs.get_entity(1)"), Err(NO_HOST.to_string())); + } + + #[test] + fn add_text_left_validates_before_reaching_host() { + assert_eq!( + eval(r#"ocs.add_text_left("Note", 0.0, 0.0, 0.0, 2.0, 0.0)"#), + Err(NO_HOST.to_string()) + ); + let bad = eval(r#"ocs.add_text_left("Note", 0.0, 0.0, 0.0, 0.0, 0.0)"#); + assert!(matches!(bad, Err(ref msg) if msg.starts_with("ValueError: ocs.add_text_left:"))); + } +} diff --git a/plugins/opencad-python/src/selection_cache.rs b/plugins/opencad-python/src/selection_cache.rs new file mode 100644 index 000000000..8a6313462 --- /dev/null +++ b/plugins/opencad-python/src/selection_cache.rs @@ -0,0 +1,33 @@ +//! Process-wide cache of the most recently observed selection. +//! +//! `HostApi` has no synchronous "give me the current selection" call — only +//! the best-effort `SelectionChanged` / `SelectionChangedV4` notification. +//! Naively calling `HostApi::try_recv_notification()` from inside `dispatch` +//! doesn't work: `ocs_plugin_api::runner`'s own V4 event loop +//! (`run_v4` in `runner.rs`) drains that queue itself on every iteration, +//! forwarding each notification to `BuiltinPlugin::on_notification` *before* +//! a `Dispatch` request ever reaches `dispatch()` — so by the time `dispatch` +//! runs, the queue is always empty. The only way to observe notifications is +//! to override `on_notification` and stash what's needed here; `dispatch` +//! (and the `ocs.selection()` pyfunction) then just reads this cache. +//! +//! A plain process-wide static is fine here: one plugin process hosts exactly +//! one `PythonPlugin`, so this is equivalent to an instance field, but a +//! static is far simpler to reach from the `#[pymodule]` native functions in +//! `ocs_module.rs`, which have no path back to the `BuiltinPlugin` instance. + +use std::sync::Mutex; + +use ocs_plugin_api::host::Handle; + +static CACHE: Mutex> = Mutex::new(Vec::new()); + +pub(crate) fn set(handles: Vec) { + if let Ok(mut cache) = CACHE.lock() { + *cache = handles; + } +} + +pub(crate) fn get() -> Vec { + CACHE.lock().map(|cache| cache.clone()).unwrap_or_default() +} diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py new file mode 100644 index 000000000..bbb746616 --- /dev/null +++ b/plugins/opencad-python/tests/test_document_model.py @@ -0,0 +1,124 @@ +import pathlib +import types +import unittest + + +MODEL = pathlib.Path(__file__).resolve().parents[1] / "src" / "document_model.py" + + +class DocumentModelTests(unittest.TestCase): + def setUp(self): + self.records = { + 1: {"handle": 1, "kind": "Line", "layer": "0", "start": {"x": 1, "y": 2, "z": 3}, + "end": {"x": 4, "y": 5, "z": 6}, "_editable": True}, + 2: {"handle": 2, "kind": "Hatch", "layer": "0", "_editable": False}, + } + self.calls = [] + self.ocs = types.SimpleNamespace( + entity_descriptor=lambda handle: self.records.get(handle), + entity_coverage=lambda kind: {"kind": kind, "scope": "canvas", + "editable": ["start", "end", "layer"] if kind == "Line" else ["layer"], + "readable": ["handle", "kind", "layer"], "unmapped": []}, + entity_kinds=lambda: ["Line", "Hatch"], + entity_snapshot=lambda handle: {self.records[handle]["kind"]: { + "common": {"layer": self.records[handle]["layer"]}, + "pattern_name": "SOLID" if handle == 2 else None, + }} if handle in self.records else None, + entity_handles=lambda: list(self.records), + update_many=lambda label, updates: self.calls.append((label, updates)), + add=lambda entity: self.calls.append(("add", entity)) or 1, + remove_entity=lambda handle: self.calls.append(("remove", handle)), + selection=lambda: [1], + select=lambda handles: self.calls.append(("selection", handles)), + poll_events=lambda tab: [{"type": "drawing", "tab_id": tab}], + tab_id=lambda: 7, + request_input=lambda prompt, entity: (prompt, entity), + poll_input=lambda token: {"status": "point", "point": [1, 2, 3]}, + ) + namespace = {"ocs": self.ocs} + exec(MODEL.read_text(), namespace) + self.doc = self.ocs.active_document + + def test_batch_edit_and_rollback(self): + line = self.doc.entities[1] + self.assertEqual(line.start, (1, 2, 3)) + with self.doc.transaction("Move line"): + line.start = (0, 0, 0) + line.end = (10, 0, 0) + self.assertEqual(line.end, (10, 0, 0)) + self.assertEqual(self.calls, [("Move line", [{"handle": 1, + "start": {"x": 0, "y": 0, "z": 0}, "end": {"x": 10, "y": 0, "z": 0}}])]) + with self.assertRaises(ValueError): + with self.doc.transaction("Abort"): + line.layer = "A" + raise ValueError() + self.assertEqual(len(self.calls), 1) + + def test_create_and_delete_use_document_model(self): + line = self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, + end={"x": 10, "y": 0, "z": 0}) + self.assertEqual(line.handle, 1) + self.assertEqual(self.calls[-1], ("add", {"kind": "Line", + "start": {"x": 0, "y": 0, "z": 0}, "end": {"x": 10, "y": 0, "z": 0}})) + self.doc.delete_entity(line) + self.assertEqual(self.calls[-1], ("remove", 1)) + with self.assertRaises(ValueError): + self.doc.create_entity("Line", handle=4) + + def test_coverage_and_read_only_entity(self): + self.assertEqual(len(self.doc.entities), 2) + self.assertEqual([entity.handle for entity in self.doc.entities], [1, 2]) + self.assertIn("start", self.doc.entities[1].coverage["editable"]) + self.assertEqual(self.doc.entities[2].coverage["editable"], ["layer"]) + self.assertEqual(set(self.doc.coverage()), {"Line", "Hatch"}) + self.assertEqual(self.doc.coverage("Hatch")["editable"], ["layer"]) + snapshot = self.doc.entities[2].snapshot + self.assertEqual(snapshot["pattern_name"], "SOLID") + snapshot["pattern_name"] = "OTHER" + self.assertEqual(self.doc.entities[2].snapshot["pattern_name"], "SOLID") + with self.doc.transaction("Move hatch"): + self.doc.entities[2].layer = "HATCHES" + self.assertEqual(self.calls[-1], ("Move hatch", [{"handle": 2, "layer": "HATCHES"}])) + with self.doc.transaction("Unsupported geometry"): + with self.assertRaises(AttributeError): + self.doc.entities[2].pattern_name = "OTHER" + + def test_rejected_host_transaction_leaves_python_model_ready(self): + def reject(label, updates): + raise ValueError("Line.start must contain finite coordinates") + self.ocs.update_many = reject + with self.assertRaisesRegex(ValueError, "finite coordinates"): + with self.doc.transaction("Invalid"): + self.doc.entities[1].start = (float("nan"), 0, 0) + self.assertIsNone(self.doc._pending) + self.assertEqual(self.doc.entities[1].start, (1, 2, 3)) + + def test_readable_insert_identity_cannot_be_patched(self): + self.records[3] = {"handle": 3, "kind": "Insert", "layer": "0", + "block_name": "DOOR", "insert_point": {"x": 1, "y": 2, "z": 0}, + "_editable": True} + old_coverage = self.ocs.entity_coverage + self.ocs.entity_coverage = lambda kind: ( + {"kind": "Insert", "scope": "canvas", "editable": ["layer", "insert_point"], + "readable": ["kind", "handle", "layer", "block_name", "insert_point"], + "unmapped": []} if kind == "Insert" else old_coverage(kind)) + insert = self.doc.entities[3] + self.assertEqual(insert.block_name, "DOOR") + with self.doc.transaction("Move block"): + with self.assertRaises(AttributeError): + insert.block_name = "WINDOW" + insert.insert_point = (4, 5, 0) + self.assertEqual(self.calls[-1], ("Move block", [{"handle": 3, + "insert_point": {"x": 4, "y": 5, "z": 0}}])) + + def test_selection_events_and_input(self): + self.assertEqual([entity.handle for entity in self.doc.selection], [1]) + self.doc.selection = [self.doc.entities[1]] + self.assertEqual(self.calls[-1], ("selection", [1])) + self.assertEqual(self.doc.poll_events(), [{"type": "drawing", "tab_id": 7}]) + self.assertEqual(self.doc.request_entity("Pick"), ("Pick", True)) + self.assertEqual(self.doc.poll_input(10)["status"], "point") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/opencad-python/tools/stage-bundled.sh b/plugins/opencad-python/tools/stage-bundled.sh new file mode 100755 index 000000000..65da83240 --- /dev/null +++ b/plugins/opencad-python/tools/stage-bundled.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Build the in-tree API v7 Python plugin and stage a complete plugin package. +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: $0 [--debug] [--target ]" >&2 + exit 2 +fi + +stage_dir=$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$1") +shift +profile=release +target="" +while [[ $# -gt 0 ]]; do + case "$1" in + --debug) profile=debug; shift ;; + --target) + [[ $# -ge 2 ]] || { echo "--target requires a triple" >&2; exit 2; } + target=$2 + shift 2 + ;; + *) echo "unknown option: $1" >&2; exit 2 ;; + esac +done + +plugin_dir=$(cd "$(dirname "$0")/.." && pwd) +ocs_repo=$(cd "$plugin_dir/../.." && pwd) +cd "$plugin_dir" + +build_args=(build --locked --features experimental-host-model) +[[ $profile == release ]] && build_args+=(--release) +[[ -n $target ]] && build_args+=(--target "$target") +cargo "${build_args[@]}" + +case "$(uname -s)" in + Darwin) library="libopencad_python.dylib" ;; + Linux) library="libopencad_python.so" ;; + MINGW*|MSYS*|CYGWIN*) library="opencad_python.dll" ;; + *) echo "unsupported platform" >&2; exit 2 ;; +esac + +target_dir="$plugin_dir/target" +[[ -n $target ]] && target_dir="$target_dir/$target" +source_library="$target_dir/$profile/$library" +[[ -f $source_library ]] || { echo "build did not produce $source_library" >&2; exit 1; } + +mkdir -p "$stage_dir" +cp "$source_library" "$stage_dir/$library" +python3 - "$stage_dir/plugin.toml" "$(rustc --version)" "$ocs_repo/Cargo.lock" <<'PY' +from pathlib import Path +import re +import sys + +def acadrust_source(path): + packages = re.findall(r"(?ms)^\[\[package\]\]\n(.*?)(?=^\[\[package\]\]|\Z)", Path(path).read_text()) + sources = [] + for package in packages: + if re.search(r'^name = "acadrust"$', package, re.M): + match = re.search(r'^source = "([^"]+)"$', package, re.M) + if match: + sources.append(match.group(1)) + if len(sources) != 1: + raise SystemExit(f"expected one acadrust source in {path}, found {len(sources)}") + return sources[0] + +manifest = Path("plugin.toml").read_text() +if "api_version = 7" not in manifest: + raise SystemExit("bundled plugin.toml must target API v7") +manifest = manifest.replace("__RUSTC_VERSION__", sys.argv[2]) +manifest = manifest.replace("__ACADRUST_SOURCE__", acadrust_source(sys.argv[3])) +if "__" in manifest: + raise SystemExit("unresolved manifest placeholder") +Path(sys.argv[1]).write_text(manifest) +PY +echo "Staged bundled API v7 plugin in $stage_dir" diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 493696def..2edf4be6b 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -15,7 +15,8 @@ #![cfg_attr(target_arch = "wasm32", allow(dead_code))] -use std::path::PathBuf; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; /// One entry in the curated plugin registry (`plugins/registry.json`). #[derive(Debug, Clone)] @@ -68,6 +69,9 @@ pub struct ExternalPlugin { pub command_prefixes: Vec, /// The package directory under the plugins folder. pub dir: PathBuf, + /// True when the package ships inside the application rather than the + /// per-user plugin directory. + pub bundled: bool, /// Whether a native library for this platform sits beside `plugin.toml`. pub lib_present: bool, } @@ -147,6 +151,24 @@ pub fn plugins_dir() -> Option { Some(p) } +/// Read-only plugins shipped with the application. The environment override +/// makes development and packaging tests independent of a particular bundle +/// layout. +pub fn bundled_plugins_dir() -> Option { + if let Ok(p) = std::env::var("OCS_BUNDLED_PLUGINS_DIR") { + return Some(PathBuf::from(p)); + } + let exe = std::env::current_exe().ok()?; + #[cfg(target_os = "macos")] + { + return Some(exe.parent()?.parent()?.join("Resources").join("plugins")); + } + #[cfg(not(target_os = "macos"))] + { + Some(exe.parent()?.join("plugins")) + } +} + /// Delete an installed package's folder. It stays loaded for the current /// session (the library is resident); the removal takes effect on next start. #[cfg(not(target_arch = "wasm32"))] @@ -171,16 +193,33 @@ fn lib_extension() -> &'static str { } } -/// Discover every package under the plugins directory, sorted by `ribbon_order` -/// then id. Missing directory → empty list (not an error). +/// Discover bundled and per-user packages, sorted by `ribbon_order` then id. +/// A per-user package with the same id explicitly overrides the bundled copy. +/// Missing directories are ignored. pub fn discover() -> Vec { - let Some(root) = plugins_dir() else { - return Vec::new(); + discover_from_roots(bundled_plugins_dir().as_deref(), plugins_dir().as_deref()) +} + +fn discover_from_roots( + bundled_root: Option<&Path>, + user_root: Option<&Path>, +) -> Vec { + let mut found = BTreeMap::new(); + if let Some(root) = bundled_root { + discover_root(root, true, &mut found); + } + if let Some(root) = user_root { + discover_root(root, false, &mut found); + } + let mut found: Vec<_> = found.into_values().collect(); + found.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(&b.id))); + found +} + +fn discover_root(root: &Path, bundled: bool, found: &mut BTreeMap) { + let Ok(entries) = std::fs::read_dir(root) else { + return; }; - let Ok(entries) = std::fs::read_dir(&root) else { - return Vec::new(); - }; - let mut found = Vec::new(); for entry in entries.flatten() { let dir = entry.path(); if !dir.is_dir() { @@ -198,11 +237,10 @@ pub fn discover() -> Vec { } p.lib_present = lib_present_in(&dir); p.dir = dir; - found.push(p); + p.bundled = bundled; + found.insert(p.id.clone(), p); } } - found.sort_by(|a, b| a.ribbon_order.cmp(&b.ribbon_order).then(a.id.cmp(&b.id))); - found } /// True when a file with this platform's dynamic-library extension exists in @@ -290,6 +328,7 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { ribbon_order, command_prefixes, dir: PathBuf::new(), + bundled: false, lib_present: false, }) } @@ -523,6 +562,42 @@ xdata_apps = ["MYPLUGIN_RECORD"] assert!(parse_plugin_toml("name = \"x\"").is_none()); } + #[test] + fn discovers_bundled_plugins_and_allows_a_user_override() { + let base = std::env::temp_dir().join(format!( + "ocs-bundled-plugin-discovery-{}", + std::process::id() + )); + let bundled = base.join("bundled").join("opencad.python"); + let user = base.join("user").join("opencad.python"); + std::fs::create_dir_all(&bundled).unwrap(); + std::fs::create_dir_all(&user).unwrap(); + let manifest = |version: &str| { + format!( + "[plugin]\nid=\"opencad.python\"\nversion=\"{version}\"\n[opencad]\napi_version=7\n" + ) + }; + std::fs::write(bundled.join("plugin.toml"), manifest("bundled")).unwrap(); + std::fs::write(user.join("plugin.toml"), manifest("user")).unwrap(); + std::fs::write( + bundled.join(format!("plugin.{}", lib_extension())), + b"fixture", + ) + .unwrap(); + std::fs::write(user.join(format!("plugin.{}", lib_extension())), b"fixture").unwrap(); + + let bundled_only = discover_from_roots(Some(&base.join("bundled")), None); + assert_eq!(bundled_only.len(), 1); + assert!(bundled_only[0].bundled); + assert_eq!(bundled_only[0].version, "bundled"); + + let overridden = discover_from_roots(Some(&base.join("bundled")), Some(&base.join("user"))); + assert_eq!(overridden.len(), 1); + assert!(!overridden[0].bundled); + assert_eq!(overridden[0].version, "user"); + std::fs::remove_dir_all(base).unwrap(); + } + #[test] fn incompatible_api_flagged() { let p = parse_plugin_toml("id=\"a\"\napi_version = 9999").unwrap(); diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index a1965b8be..1ac31a2ab 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -1,6 +1,6 @@ -// Open CAD Studio plugin runtime. Plugins are external cdylibs loaded from the -// user plugins folder (see `external`) and installed via the marketplace; the -// host ships no built-in add-ons. See `docs/plugin-architecture.md`. +// Open CAD Studio plugin runtime. Plugins are out-of-process cdylibs loaded +// from the application bundle or user plugins folder (see `external`). +// See `docs/plugin-architecture.md`. pub mod external; pub mod host; @@ -81,4 +81,4 @@ mod tests { "expected an error queued for the command line, got: {queued:?}" ); } -} \ No newline at end of file +} diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index 341fc09a6..753038da7 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -314,11 +314,19 @@ fn external_card<'a>( actions = actions.push(toggle_button(&p.id, disabled)); actions = actions.push(Space::new().width(6)); } - actions = actions.push(pill_button( - t!("Uninstall"), - Message::PluginUninstall(p.id.clone()), - button::danger, - )); + if p.bundled { + actions = actions.push( + text(t!("Bundled with Open CAD Studio")) + .size(11) + .style(muted_style), + ); + } else { + actions = actions.push(pill_button( + t!("Uninstall"), + Message::PluginUninstall(p.id.clone()), + button::danger, + )); + } container(column![info, actions].spacing(8).padding([10, 12])) .width(Fill) From a8dd3640b395a0f3defc1a3c0b8221f41ad03707 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 09:43:47 +0200 Subject: [PATCH 16/76] Refresh Python host model handoff documentation --- docs/plugin-host-model-completion-plan.md | 66 ++++++++++++++++++----- docs/plugin-host-model-coverage-ledger.md | 12 +++-- docs/plugin-host-model.md | 27 +++++----- 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 77a807f69..6e64de4d2 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -4,31 +4,51 @@ Build a general Python document model over the OCS host API for all **43 canvas kinds**. A kind is **complete** only after the per-kind gate below passes. Merely appearing in a converter or allowing a `layer` edit is partial coverage. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). Do not resume the separate Lisp-to-Python conversion work. -As of 19 September 2026, the current Hatch increment is OCS commit `98b56ef6` on `plugin/host-model-api` and PandoraBox commit `a9b5de8` on `main`. Host API is v7. The Python feature has explicit writes for **22 of 43** canvas kinds: 21 are creatable and `Insert` remains update-only. This is **not** 22 completed kinds: Tolerance, Shape, AttributeEntity and Hatch pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the pinned acadrust DXF reader drops optional ATTDEF fields. The other 21 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. +As of 20 September 2026, the working branch is `plugin/host-model-api` at +`8648225a`, published as draft PR +[#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is +v7. The Python feature has explicit writes for **22 of 43** canvas kinds: 21 +are creatable and `Insert` remains update-only. This is **not** 22 completed +kinds: Tolerance, Shape, AttributeEntity and Hatch pass every completion gate. +AttributeDefinition passes its host and real IPC lifecycle but remains short of +`Complete` because the previously pinned CAD codec dropped optional ATTDEF +fields during DXF reads; revalidate that blocker against the current +`acadrust` revision `7ea4247`. The other 21 canvas kinds have only `layer` +writes through the document model. All 43 have raw typed snapshots. There are +three internal records and two opaque fallbacks outside the 43 canvas kinds. Repositories: - OCS host: `/Users/felix/Documents/MacApps/OpenCADStudio` -- Python adapter: `/Users/felix/Documents/MacApps/opencad-python/plugins/opencad-python` +- Bundled Python adapter: `/Users/felix/Documents/MacApps/OpenCADStudio/plugins/opencad-python` - Host coverage policy: `crates/ocs_plugin_api/entity_coverage_policy.json` - Host coverage/validation: `crates/ocs_plugin_api/src/entity_coverage.rs` and `build.rs` -- Adapter mapping: `entity_manifest.json`, `build/generate.rs`, `src/ocs_module.rs`, `src/document_model.py` +- Adapter mapping: `plugins/opencad-python/entity_manifest.json`, + `plugins/opencad-python/build/generate.rs`, + `plugins/opencad-python/src/ocs_module.rs`, and + `plugins/opencad-python/src/document_model.py` - Host design notes: `docs/plugin-host-model.md` -Preserve unrelated untracked files in both repositories. Push OCS changes only to the `felixriestra/OpenCADStudio` fork, not H7 upstream; do not open a PR without asking. The prior user approved pushing work to the existing OCS and PandoraBox repositories, but verify remote branch tips before each push. +Preserve unrelated untracked files. Host and adapter changes now live in the +same OCS repository and commit. Push the feature branch only to the +`felixriestra/OpenCADStudio` fork; PR #1391 updates automatically. Never push a +feature commit directly to H7's `main`. Verify both upstream `main` and the fork +branch before each push. ## First: complete the originally agreed Phase 8 The earlier Phase 8 was **integration and compatibility**, not a new entity kind. `Insert` was added afterward as a separate coverage increment. Before marking any kind complete, establish an executable integration harness that exercises the actual plugin runner and IPC, not only direct Rust conversion helpers: -1. Stage the development v7 plugin with `bash plugins/opencad-python/tools/stage-host-model.sh [--debug]`. Verify the staged `plugin.toml` says API v7 and its acadrust source matches OCS. Keep checked-in PandoraBox `plugin.toml` and `Cargo.lock` on H7 API v5; keep `Cargo.toml` free of machine-local paths. +1. Stage the in-tree v7 plugin with `bash plugins/opencad-python/tools/stage-bundled.sh [--debug]`. Verify the staged `plugin.toml` says API v7 and its acadrust source and Rust compiler match OCS. Keep the repository-relative `ocs_plugin_api` path; never check in a machine-local path. 2. Through real IPC, create, inspect, edit, and delete a representative entity. Verify an invalid multi-entity transaction changes nothing, a valid transaction produces one undo entry, and undo **and redo** restore the expected geometry and references. 3. In the running GUI, exercise a real point pick, entity pick, cancellation/Escape, and plugin command cancellation. Verify drawing/selection/command notifications, including overflow recovery. 4. Repeat an input request and event poll across two tabs; verify token and notification isolation and cleanup when a tab closes. 5. Save and reopen representative edits as **DWG and DXF**, verifying the actual geometry and preserved unmapped fields. Use a real viewport/canvas check where the entity has visible geometry; a converter round trip alone does not satisfy this. 6. Record platform, OCS/acadrust revisions, fixture files, commands, results, and any engine limitation. Keep repeatable tests in the relevant repositories. -Phase 8 is complete only when these checks pass without weakening the portable v5 build. If a test is blocked by OCS or acadrust behavior, record the specific failure and leave Phase 8 open. +Phase 8 is complete only when these checks pass through the bundled plugin and +real runner boundary. If a test is blocked by OCS or acadrust behavior, record +the specific failure and leave Phase 8 open. ## Per-kind completion gate @@ -40,7 +60,10 @@ Use one checklist row per kind in a coverage ledger. Status values: `unstarted`, 4. **Delete and history:** Delete the created entity through the Python interface. Prove create, edit, and delete have defined undo/redo behavior in the host, including grouped writes and failure rollback. Record any transaction API extension and bump the host API version if its ABI changes; keep older plugin compatibility. 5. **Real IPC and canvas:** Run the above through the staged plugin and actual OCS IPC. Verify visibility/geometry in the canvas and selection or picking in the GUI. For nonvisual entities, verify their observable document or layout behavior and explain the oracle. 6. **Persistence:** Save/reopen DWG **and** DXF fixtures. Compare kind, geometry, important metadata, handles/references, and preserved fields after reopen; edit again after reopen. Test at least one valid edge case for that kind. Do not count a JSON or in-memory round trip as file persistence. -7. **Portable build:** Run feature tests with a command-line Cargo patch to the OCS fork, restore PandoraBox `Cargo.lock`, then run `cargo check --locked` against the checked-in H7 v5 dependency. Never check in a local Cargo path or v7 portable manifest. +7. **Packaged build:** Run the in-tree adapter tests with `--locked`, stage the + plugin with `stage-bundled.sh`, and load it from the macOS application + resources through the separate runner process. The staged manifest must + match the host's Rust compiler and acadrust source. If OCS/acadrust cannot create or serialize a kind reliably, mark it **blocked**, add a regression test or fixture demonstrating the failure, and move to the next independent kind. An explicit unsupported status is honest progress, not completion. @@ -88,18 +111,35 @@ The last five may require new engine capabilities rather than only Python conver 1. Inspect the acadrust entity struct, constructors, private fields, serializer, drawing path, and linked tables/objects. Record the intended editable/read-only/unmapped properties first. 2. Put generic validation and transaction behavior in the OCS fork. Keep the Python adapter thin; add a manifest override only when the generator cannot safely express a field. 3. Add focused host validation, adapter conversion, document-model, real transaction/undo/redo, IPC, GUI, and DWG/DXF fixture checks. Record test evidence in the ledger, including which checks were manual. -4. Run `cargo test -p ocs_plugin_api --features host --lib` and relevant app host tests in OCS. For the adapter, use `cargo test --features experimental-host-model --config 'patch."https://github.com/HakanSeven12/OpenCADStudio".ocs_plugin_api.path="/Users/felix/Documents/MacApps/OpenCADStudio/crates/ocs_plugin_api"' --lib`, run its Python tests, restore `Cargo.lock`, then run `cargo check --locked`. -5. Check both `git diff --check` and repository status. Stage only task files, preserve unrelated untracked files, commit coherent host/adapter changes, and push only to the two established `felixriestra` remotes if still authorized. Record the resulting commits and next unfinished gate in the ledger. +4. Run `cargo test -p ocs_plugin_api --features host --lib` and the relevant + app host tests. Run + `cargo test --locked --features experimental-host-model --manifest-path plugins/opencad-python/Cargo.toml` + plus + `python3 -m unittest discover -s plugins/opencad-python/tests -v`. +5. Check `git diff --check` and repository status. Stage only task files, + preserve unrelated untracked files, commit host and adapter changes + together, push `plugin/host-model-api` to the Felix fork, and record the + resulting commit and next unfinished gate in the ledger. ## Handoff start point -Continue with **Leader**. Build a valid vertex path and annotation-linked fixture, map geometry separately from annotation/object handles, and verify path rendering, selection, reference preservation, deletion, undo/redo and both file formats through real IPC. AttributeDefinition remains mapped and integration-tested but open at W: pinned acadrust `568a12c` loses optional ATTDEF fields such as width factor on DXF read. Do not mark it complete until that dependency is fixed and the full property state passes DXF save/reopen. Keep the exact queue above and update statuses with evidence after each increment. +Continue with **Leader**. Build a valid vertex path and annotation-linked +fixture, map geometry separately from annotation/object handles, and verify +path rendering, selection, reference preservation, deletion, undo/redo and +both file formats through real IPC. AttributeDefinition remains mapped and +integration-tested but open at W. First rerun its DXF regression against +current acadrust `7ea4247`; do not mark it complete until the full optional +property state passes DXF save/reopen. Keep the exact queue above and update +statuses with evidence after each increment. ## Phase 8 progress log - Added a V4 local-socket nested-request test for `UpdateEntitiesTransaction` using a simulated runner peer and a recording host. This proves the request and response cross the V4 IPC framing and reach `HostApi`; it does **not** prove the actual Python plugin runs through IPC. - Extended the OCS app-host Point batch test to assert redo after undo. This proves the real app history path for those edits; creation/deletion history remains untested by this case. -- Staged the actual debug Python cdylib with `stage-host-model.sh`. Its generated manifest declares API v7 and the OCS acadrust source; the checked-in PandoraBox manifest and lockfile remain on H7 v5, and `cargo check --locked` passes. +- Staged the actual debug Python cdylib with `stage-bundled.sh`. Its generated + manifest declares API v7 and the exact OCS acadrust and Rust compiler + fingerprints. The plugin now builds from `plugins/opencad-python` in this + repository and ships from the macOS application resources. - The opt-in `staged_python_plugin_line_lifecycle_over_real_ipc` app test passed with the staged v7 Python plugin and built OCS executable. It creates, edits, and deletes a Line through Python over the actual runner IPC; confirms three undo entries and undo/redo; and writes/reopens the edited Line as DWG and DXF. It does not exercise GUI picking, multiple tabs, or cancellation. Re-run with `OCS_TEST_PYTHON_PLUGIN= OCS_PLUGIN_RUNNER_EXE= cargo test app::plugin_host::tests::staged_python_plugin_line_lifecycle_over_real_ipc --lib -- --test-threads=1 --nocapture` from the OCS checkout. - The same Line test now also sends a duplicate-handle batch through Python and confirms the host rejects it without changing geometry or creating an undo entry. - The opt-in `staged_python_point_pick_and_cancel_over_real_ipc` test passes with the same runner. It requests point and entity picks through Python, drives the app's active-command point/object-pick callbacks, polls the returned tokens through a later Python run, and verifies Enter cancellation. The callbacks are invoked by the test harness, not by a physical GUI pointer event; live GUI verification remains open. @@ -111,8 +151,8 @@ Continue with **Leader**. Build a valid vertex path and annotation-linked fixtur - `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the actual runner, checks the real SHX linework before and after DWG/DXF reopen, and proves that invalid size/style edits remain atomic. The DXF renderer fallback now searches named `is_shape_file` styles as well as legacy unnamed styles. Shape is **Complete**; the queue now continues with `AttributeDefinition`. - Added creation-time `owner_handle` to the generated document-model adapter. It is present in snapshots and accepted by `doc.create_entity`, but rejected after creation; host transactions independently reject owner changes. Generic host validation now requires a non-null canvas owner to resolve to a block record, except `AttributeEntity`, whose owner may be an `Insert`. - Mapped stable AttributeDefinition fields and validated tag, placement, normal, height, width, angles, text style, line count and block ownership. `embedded_mtext` stays snapshot-readable but unmapped so partial Python edits preserve its linked R2018+ layout payload. -- `staged_python_attribute_definition_lifecycle_over_real_ipc` passes the real create/read/edit/delete/undo/selection/render/validation/portable-build path using a structurally valid block and linked Insert. Full DWG state and core DXF geometry survive reopen; deletion stays absent after both formats. OCS also stopped double-converting ATTDEF/ATTRIB DXF rotation already converted by acadrust. -- AttributeDefinition remains **Integration-tested / DXF blocked**, not Complete: acadrust `568a12c`'s ATTDEF DXF reader does not restore optional AcDbText fields including width factor. The real test and ledger record that exact W-gate gap. The queue now continues with `AttributeEntity`. +- `staged_python_attribute_definition_lifecycle_over_real_ipc` passes the real create/read/edit/delete/undo/selection/render/validation/packaged-build path using a structurally valid block and linked Insert. Full DWG state and core DXF geometry survive reopen; deletion stays absent after both formats. OCS also stopped double-converting ATTDEF/ATTRIB DXF rotation already converted by acadrust. +- AttributeDefinition remains **Integration-tested / DXF blocked**, not Complete: acadrust `568a12c`'s ATTDEF DXF reader did not restore optional AcDbText fields including width factor. Revalidate this recorded failure against the currently merged `7ea4247` revision before keeping or clearing the W-gate blocker. - Closed `AttributeEntity` without flattening canonical drawing storage. V4 views and IPC snapshots expose each nested child by handle, while host create/update/delete and transactions rewrite the owning Insert so rendering, serialization and undo remain consistent. - `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the actual v7 adapter. It creates a linked attribute from its block's ATTDEF, reads and edits value/placement/rotation, selects the parent Insert, renders text, rejects invalid tag/height/owner edits atomically, reopens edited and deleted state in DWG and DXF, and proves three-step undo/redo. AttributeEntity is **Complete**; the queue now continues with `Hatch`. - Completed `Hatch` with full solid/pattern definitions and typed nested boundary paths. The adapter generator now handles single-payload tagged enums as `{kind, value}` dictionaries, which covers every Hatch edge variant and is reusable by later nested entity models. Unknown adapter properties are rejected instead of being silently ignored. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index fdd3e3913..5d4626d07 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,8 +1,8 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 19 September 2026: **22 of 43 canvas kinds have explicit geometry writes** (21 creatable, plus update-only `Insert`), **4 kinds are complete**, and 21 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **22 of 43 canvas kinds have explicit geometry writes** (21 creatable, plus update-only `Insert`), **4 kinds are complete**, and 21 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. -Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = portable H7 v5 build. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. +Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. | Kind | Baseline | Gate evidence / blocker | |---|---|---| @@ -25,7 +25,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Insert | Update-only | Transform transaction and undo tested; Python creation, deletion, real IPC/GUI and DWG/DXF gates pending. | | Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | | Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | -| AttributeDefinition | **Integration-tested / DXF blocked** | Host commit `5b854bd1` and adapter commit `bb259ff` map block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, render text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open** because pinned acadrust `568a12c` drops optional ATTDEF fields including width factor while reading DXF; the regression test keeps this limitation explicit. `embedded_mtext` mutation also remains unsupported pending linked-layout validation. | +| AttributeDefinition | **Integration-tested / DXF blocker pending revalidation** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, rendered text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open:** acadrust `568a12c` dropped optional ATTDEF fields including width factor while reading DXF. Rerun the regression against the currently merged `7ea4247` revision and update this row with the result. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | | AttributeEntity | **Complete** | OCS commit `cf2a9235` and adapter commit `9c6b5c3` make nested ATTRIB records addressable by handle while retaining canonical storage in `Insert.attributes`. Host validation binds tag to a definition in the Insert's block, validates placement/text geometry and style references, keeps owner and definition handles read-only, and preserves embedded MTEXT. `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a real block, ATTDEF and Insert: high-level create/read/transaction edit/delete, parent selection, rendered text, atomic invalid tag/height/owner rejection, edited and deleted DWG/DXF reopen, and three-step undo/redo. `nested_insert_attribute_crud_uses_parent_storage_and_undo` additionally proves the child never becomes an orphan flat entity in the host document. | | Hatch | **Complete** | OCS commit `98b56ef6` and adapter commit `a9b5de8` expose solid and patterned Hatches with elevation/normal, complete pattern definitions, pattern controls, style, association state, boundary paths and seed points. Nested tagged boundary edges use the reusable `{kind, value}` adapter form. Host validation requires closed finite loops, validates line/arc/ellipse/polyline/spline payloads and stored pattern lines, resolves associative boundary handles, and preserves gradient and MPOLYGON payloads as explicitly unsupported. `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: Python solid creation, patterned boundary edit, live render-model pattern/boundary checks, selection, atomic invalid scale/association/open-loop/unmapped-property rejection, edited and deleted DWG/DXF reopen, post-reopen edits, and three-step undo/redo. | | Leader | Layer-only | Requires annotation-linked fixture. | @@ -50,4 +50,8 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Surface | Layer-only | Requires valid surface payload. | | Ole2Frame | Layer-only | Requires valid embedded-object storage. | -After each increment, update the row with test names, fixture paths, exact passing commands, OCS and PandoraBox commit hashes, and any manual GUI result. If a gate is untested, leave it pending. Do not raise the headline completion count from converter coverage alone. +After each increment, update the row with test names, fixture paths, exact +passing commands, the OCS commit hash, and any manual GUI result. Host and +adapter changes now share the same repository commit. If a gate is untested, +leave it pending. Do not raise the headline completion count from converter +coverage alone. diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 37bcb69f2..03ece7f70 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 17 geometry converters. Other writable properties +canvas kinds outside its 22 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -46,20 +46,23 @@ write path for them. The Python adapter exposes its variant payload through It is an inspection view: JSON converts non-finite floating-point values to null and cannot be used as a lossless replacement entity. -The separate PandoraBox adapter currently maps these entity kinds to Python -dictionaries: Point, Line, Circle, Arc, Ellipse, Polyline, Polyline2D, -Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, and -Insert. Insert is update-only: the block name is readable but cannot be -changed, and Python creation is withheld until block-table references can be -established safely. Attached attributes remain available in the raw snapshot. -The authoritative per-field mapping -is `plugins/opencad-python/entity_manifest.json` in PandoraBox. The generated +The bundled first-party adapter under `plugins/opencad-python` currently maps +these entity kinds to Python dictionaries: Point, Line, Circle, Arc, Ellipse, +Polyline, Polyline2D, Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, +Solid, Face3D, Insert, Tolerance, Shape, AttributeDefinition, +AttributeEntity, and Hatch. Insert is update-only: the block name is readable +but cannot be changed, and Python creation is withheld until block-table +references can be established safely. Attached attributes remain available in +the raw snapshot. The authoritative per-field mapping is +`plugins/opencad-python/entity_manifest.json`. The generated mapping excludes fields it cannot represent, including common color, line weight, transparency, Polyline3D smooth type, and MText background color. Absent geometry fields are not editable through the Python document model. A feature test compares the generated Python keys with the host catalog. The adapter's `experimental-host-model` feature requires API -v7; its portable default remains pinned to H7's v5 contract. +v7 and builds against the repository-relative `ocs_plugin_api`. RustPython is +loaded inside the separate plugin runner process; OCS does not link the Python +runtime into the editor executable. API v7 also exposes a synchronous, ordered selection query and an atomic selection replacement scoped to the session tab. All handles must exist before @@ -74,7 +77,7 @@ noninteractive command that starts and finishes within one message does not produce an active-state transition. The host's existing `InteractiveCommand` machinery collects either a point or -an entity pick. PandoraBox's v7 adapter now exposes a token-based request and +an entity pick. The bundled v7 adapter exposes a token-based request and poll API because `PY_RUN` uses a fresh Python interpreter per invocation; a script cannot suspend in place while the user clicks. Enter and command cancellation report a cancelled result. The host releases the runner's pick @@ -88,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 17-kind generated schema it contains only handle, kind, and layer; +outside the 22-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable From ec10bdbc48ecbae733ab318d3d4be59dc6a558ce Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 11:12:27 +0200 Subject: [PATCH 17/76] Map Leader in the Python host model; record acadrust W blockers Adds Leader validation and annotation/dimension-style reference checks to ocs_plugin_api, maps 15 writable and 5 read-only fields in the adapter, and adds staged_python_leader_lifecycle_over_real_ipc covering C/R/E/D/U/I/V/P. The W gate stays open: acadrust's DXF writer omits the annotation handle and DXF reads drop annotation_offset; DWG R2010+ does not store text size or the hookline flag. Each gap is pinned by a canary assertion. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 17 ++ .../entity_coverage_policy.json | 4 +- crates/ocs_plugin_api/src/entity_coverage.rs | 209 +++++++++++++++++ docs/plugin-host-model-completion-plan.md | 25 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 23 +- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 216 ++++++++++++++++++ 10 files changed, 486 insertions(+), 20 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index dd58dd74c..b8be77b79 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -756,6 +756,23 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | "paths" | "seed_points" ) + | ( + "Leader", + "dimension_style" + | "arrow_enabled" + | "path_type" + | "creation_type" + | "hookline_direction" + | "hookline_enabled" + | "text_height" + | "text_width" + | "vertices" + | "annotation_handle" + | "normal" + | "horizontal_direction" + | "block_offset" + | "annotation_offset" + ) ) { "transaction_geometry" } else { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 931ae0806..a960cb236 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -23,12 +23,14 @@ "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"] + "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", "annotation_handle", "normal", "horizontal_direction", "block_offset", "annotation_offset"] }, "readable": { "Insert": ["block_name"], "Tolerance": ["dimension_style_handle"], "Shape": ["style_handle"], + "Leader": ["origin", "dimension_gap", "arrowhead_type", "arrow_size", "byblock_color"], "AttributeEntity": ["attdef_handle"] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index da8c33bd3..52b808a13 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -231,6 +231,93 @@ fn validate_hatch_pattern(value: &crate::host::acadrust::entities::Hatch) -> Res Ok(()) } +/// Field checks shared by Leader creation and mutation. `old` limits the +/// checks to fields the edit touched so legacy DWG leaders stay editable. +#[cfg(feature = "host")] +fn validate_leader( + old: Option<&crate::host::acadrust::entities::Leader>, + new: &crate::host::acadrust::entities::Leader, +) -> Result<(), String> { + use crate::host::acadrust::entities::LeaderCreationType; + let vec_changed = |a: &crate::host::acadrust::types::Vector3, + b: &crate::host::acadrust::types::Vector3| { + old.is_none() + || a.x.to_bits() != b.x.to_bits() + || a.y.to_bits() != b.y.to_bits() + || a.z.to_bits() != b.z.to_bits() + }; + if old.is_none_or(|old| old.vertices != new.vertices) { + if new.vertices.len() < 2 { + return Err("Leader.vertices requires at least 2 points".into()); + } + for (index, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("Leader.vertices[{index}]"), vertex)?; + } + if new.vertices.windows(2).all(|pair| pair[0] == pair[1]) { + return Err("Leader.vertices must not all coincide".into()); + } + } + if let Some(old_leader) = old { + if vec_changed(&old_leader.normal, &new.normal) { + solid_normal("Leader.normal", &new.normal)?; + } + } else { + solid_normal("Leader.normal", &new.normal)?; + } + if vec_changed( + old.map_or(&new.horizontal_direction, |o| &o.horizontal_direction), + &new.horizontal_direction, + ) { + solid_normal("Leader.horizontal_direction", &new.horizontal_direction)?; + } + for (name, before, after) in [ + ( + "block_offset", + old.map_or(&new.block_offset, |o| &o.block_offset), + &new.block_offset, + ), + ( + "annotation_offset", + old.map_or(&new.annotation_offset, |o| &o.annotation_offset), + &new.annotation_offset, + ), + ] { + if vec_changed(before, after) { + finite_vector(&format!("Leader.{name}"), after)?; + } + } + for (name, before, after) in [ + ( + "text_height", + old.map_or(new.text_height, |o| o.text_height), + new.text_height, + ), + ( + "text_width", + old.map_or(new.text_width, |o| o.text_width), + new.text_width, + ), + ] { + if (old.is_none() || before.to_bits() != after.to_bits()) + && (!after.is_finite() || after < 0.0 || (name == "text_height" && after == 0.0)) + { + return Err(format!( + "Leader.{name} must be finite and {}", + if name == "text_height" { "greater than zero" } else { "non-negative" } + )); + } + } + if old.is_none_or(|o| o.dimension_style != new.dimension_style) + && new.dimension_style.trim().is_empty() + { + return Err("Leader.dimension_style is empty".into()); + } + if new.creation_type == LeaderCreationType::NoAnnotation && !new.annotation_handle.is_null() { + return Err("Leader with no annotation cannot carry an annotation handle".into()); + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -322,6 +409,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() return Err("Shape.style_name is empty".into()); } } + EntityType::Leader(value) => validate_leader(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -676,6 +764,7 @@ pub fn validate_entity_mutation( return Err("Shape.style_handle is read-only".into()); } } + (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { changed3( "AttributeDefinition.insertion_point", @@ -937,6 +1026,48 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::Leader(value) = entity { + use crate::host::acadrust::entities::LeaderCreationType; + if !document + .dim_styles + .iter() + .any(|style| style.name.eq_ignore_ascii_case(value.dimension_style.trim())) + { + return Err(format!( + "Leader dimension style {:?} does not exist", + value.dimension_style + )); + } + if !value.annotation_handle.is_null() { + if value.creation_type == LeaderCreationType::NoAnnotation { + return Err("Leader with no annotation cannot carry an annotation handle".into()); + } + let annotation = document + .get_entity(value.annotation_handle) + .filter(|_| value.annotation_handle != value.common.handle) + .ok_or_else(|| { + format!( + "Leader annotation handle {:?} does not exist", + value.annotation_handle + ) + })?; + let expected = match value.creation_type { + LeaderCreationType::WithText => matches!( + annotation, + EntityType::Text(_) | EntityType::MText(_) + ), + LeaderCreationType::WithTolerance => matches!(annotation, EntityType::Tolerance(_)), + LeaderCreationType::WithBlock => matches!(annotation, EntityType::Insert(_)), + LeaderCreationType::NoAnnotation => unreachable!(), + }; + if !expected { + return Err(format!( + "Leader annotation {:?} does not match creation_type {:?}", + value.annotation_handle, value.creation_type + )); + } + } + } if let EntityType::AttributeDefinition(value) = entity { let owner = entity.common().owner_handle; let block = document @@ -1202,6 +1333,7 @@ mod tests { | "AttributeDefinition" | "AttributeEntity" | "Hatch" + | "Leader" ) { assert!( @@ -1336,6 +1468,20 @@ mod tests { "Hatch.pixel_size".to_owned(), "Hatch.paths".to_owned(), "Hatch.seed_points".to_owned(), + "Leader.dimension_style".to_owned(), + "Leader.arrow_enabled".to_owned(), + "Leader.path_type".to_owned(), + "Leader.creation_type".to_owned(), + "Leader.hookline_direction".to_owned(), + "Leader.hookline_enabled".to_owned(), + "Leader.text_height".to_owned(), + "Leader.text_width".to_owned(), + "Leader.vertices".to_owned(), + "Leader.annotation_handle".to_owned(), + "Leader.normal".to_owned(), + "Leader.horizontal_direction".to_owned(), + "Leader.block_offset".to_owned(), + "Leader.annotation_offset".to_owned(), ]) ); } @@ -1368,6 +1514,69 @@ mod tests { .contains("does not exist")); } + #[cfg(feature = "host")] + #[test] + fn leader_geometry_and_annotation_references_are_validated() { + use crate::host::acadrust::{self, entities::LeaderCreationType, types::Vector3}; + let mut document = acadrust::CadDocument::new(); + let mut text = acadrust::entities::Text::new(); + text.common.handle = document.allocate_handle(); + let text_handle = text.common.handle; + document.add_entity(acadrust::EntityType::Text(text)).unwrap(); + let mut leader = acadrust::entities::Leader::two_point( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 5.0, 0.0), + ); + leader.annotation_handle = text_handle; + let entity = acadrust::EntityType::Leader(leader.clone()); + validate_new_canvas_entity(&entity).unwrap(); + validate_canvas_entity_references(&document, &entity).unwrap(); + + let mut wrong_kind = leader.clone(); + wrong_kind.creation_type = LeaderCreationType::WithTolerance; + assert!(validate_canvas_entity_references( + &document, + &acadrust::EntityType::Leader(wrong_kind) + ) + .unwrap_err() + .contains("does not match")); + let mut missing = leader.clone(); + missing.annotation_handle = acadrust::Handle::new(0xdead); + assert!(validate_canvas_entity_references( + &document, + &acadrust::EntityType::Leader(missing) + ) + .unwrap_err() + .contains("does not exist")); + let mut none_with_handle = leader.clone(); + none_with_handle.creation_type = LeaderCreationType::NoAnnotation; + assert!(validate_new_canvas_entity(&acadrust::EntityType::Leader(none_with_handle)) + .unwrap_err() + .contains("no annotation")); + let mut one_point = leader.clone(); + one_point.vertices.truncate(1); + assert!(validate_new_canvas_entity(&acadrust::EntityType::Leader(one_point)) + .unwrap_err() + .contains("at least 2")); + let mut bad_style = leader.clone(); + bad_style.dimension_style = "Missing".into(); + assert!(validate_canvas_entity_references( + &document, + &acadrust::EntityType::Leader(bad_style) + ) + .unwrap_err() + .contains("dimension style")); + + let mut edited = leader.clone(); + edited.vertices[1] = Vector3::new(f64::NAN, 0.0, 0.0); + assert!(validate_entity_mutation( + &acadrust::EntityType::Leader(leader.clone()), + &acadrust::EntityType::Leader(edited) + ) + .unwrap_err() + .contains("finite")); + } + #[cfg(feature = "host")] #[test] fn shape_geometry_and_shape_file_style_are_validated_and_bound() { diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 6e64de4d2..95f95931d 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -5,15 +5,15 @@ Build a general Python document model over the OCS host API for all **43 canvas kinds**. A kind is **complete** only after the per-kind gate below passes. Merely appearing in a converter or allowing a `layer` edit is partial coverage. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). Do not resume the separate Lisp-to-Python conversion work. As of 20 September 2026, the working branch is `plugin/host-model-api` at -`8648225a`, published as draft PR +`a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **22 of 43** canvas kinds: 21 -are creatable and `Insert` remains update-only. This is **not** 22 completed +v7. The Python feature has explicit writes for **23 of 43** canvas kinds: 22 +are creatable and `Insert` remains update-only. This is **not** 23 completed kinds: Tolerance, Shape, AttributeEntity and Hatch pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. The other 21 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 20 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,14 +123,14 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **Leader**. Build a valid vertex path and annotation-linked -fixture, map geometry separately from annotation/object handles, and verify -path rendering, selection, reference preservation, deletion, undo/redo and -both file formats through real IPC. AttributeDefinition remains mapped and -integration-tested but open at W. First rerun its DXF regression against -current acadrust `7ea4247`; do not mark it complete until the full optional -property state passes DXF save/reopen. Keep the exact queue above and update -statuses with evidence after each increment. +Continue with **MLine** (queue item 2). Leader is done except for its W +gate; when the engine writes DXF group 340, reads group 213 and the DWG +R2010+ text-size question is decided, flip the canary assertions in +`staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. +AttributeDefinition remains mapped and integration-tested but open at W; its +real IPC lifecycle now passes against acadrust `7ea4247`, so audit the full +optional-property DXF round trip and close or retain the blocker. Keep the +exact queue above and update statuses with evidence after each increment. ## Phase 8 progress log @@ -157,3 +157,4 @@ statuses with evidence after each increment. - `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the actual v7 adapter. It creates a linked attribute from its block's ATTDEF, reads and edits value/placement/rotation, selects the parent Insert, renders text, rejects invalid tag/height/owner edits atomically, reopens edited and deleted state in DWG and DXF, and proves three-step undo/redo. AttributeEntity is **Complete**; the queue now continues with `Hatch`. - Completed `Hatch` with full solid/pattern definitions and typed nested boundary paths. The adapter generator now handles single-payload tagged enums as `{kind, value}` dictionaries, which covers every Hatch edge variant and is reusable by later nested entity models. Unknown adapter properties are rejected instead of being silently ignored. - `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with closed line-loop fixtures: solid creation, conversion to a stored-line pattern, boundary replacement, selection and live render-cache checks, atomic invalid scale/association/open-loop/unmapped-payload rejection, edited/deleted DWG and DXF reopen, post-reopen edits, and three-step undo/redo. Hatch is **Complete**; the queue now continues with `Leader`. +- Mapped `Leader` (host validation and reference binding in `entity_coverage.rs`, policy/build.rs/manifest entries, adapter count 23). `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P through the real runner. W is blocked by acadrust: the DXF writer omits the annotation handle (340), DXF reads lose `annotation_offset` (213), and DWG R2010+ does not store text size or hookline flag. Each gap is pinned by a canary assertion so it fails loudly once fixed. The queue now continues with `MLine`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 5d4626d07..9e8946b1c 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **22 of 43 canvas kinds have explicit geometry writes** (21 creatable, plus update-only `Insert`), **4 kinds are complete**, and 21 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **23 of 43 canvas kinds have explicit geometry writes** (22 creatable, plus update-only `Insert`), **4 kinds are complete**, and 20 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -28,7 +28,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | AttributeDefinition | **Integration-tested / DXF blocker pending revalidation** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, rendered text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open:** acadrust `568a12c` dropped optional ATTDEF fields including width factor while reading DXF. Rerun the regression against the currently merged `7ea4247` revision and update this row with the result. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | | AttributeEntity | **Complete** | OCS commit `cf2a9235` and adapter commit `9c6b5c3` make nested ATTRIB records addressable by handle while retaining canonical storage in `Insert.attributes`. Host validation binds tag to a definition in the Insert's block, validates placement/text geometry and style references, keeps owner and definition handles read-only, and preserves embedded MTEXT. `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a real block, ATTDEF and Insert: high-level create/read/transaction edit/delete, parent selection, rendered text, atomic invalid tag/height/owner rejection, edited and deleted DWG/DXF reopen, and three-step undo/redo. `nested_insert_attribute_crud_uses_parent_storage_and_undo` additionally proves the child never becomes an orphan flat entity in the host document. | | Hatch | **Complete** | OCS commit `98b56ef6` and adapter commit `a9b5de8` expose solid and patterned Hatches with elevation/normal, complete pattern definitions, pattern controls, style, association state, boundary paths and seed points. Nested tagged boundary edges use the reusable `{kind, value}` adapter form. Host validation requires closed finite loops, validates line/arc/ellipse/polyline/spline payloads and stored pattern lines, resolves associative boundary handles, and preserves gradient and MPOLYGON payloads as explicitly unsupported. `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: Python solid creation, patterned boundary edit, live render-model pattern/boundary checks, selection, atomic invalid scale/association/open-loop/unmapped-property rejection, edited and deleted DWG/DXF reopen, post-reopen edits, and three-step undo/redo. | -| Leader | Layer-only | Requires annotation-linked fixture. | +| Leader | **Integration-tested / blocked at W by acadrust** | Fields mapped writable: `vertices` (≥2 finite points, not all coincident), `annotation_handle`, `creation_type`, `path_type`, `dimension_style`, `arrow_enabled`, `hookline_enabled`, `hookline_direction`, `text_height`, `text_width`, `normal`, `horizontal_direction`, `block_offset`, `annotation_offset`. Read-only: `origin`, `dimension_gap`, `arrowhead_type`, `arrow_size`, `byblock_color`. Unmapped but preserved on edit: `override_color` (multi-payload `Color` enum the generator cannot express) and the `dwg_unknown_*` fields. Host reference binding validates that `annotation_handle` exists, is not the leader itself, matches `creation_type` (Text/MText, Tolerance, Insert; `NoAnnotation` forbids a handle) and that `dimension_style` exists; nothing is silently defaulted. `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P against acadrust `7ea4247`: linked Text annotation, path/height/arrow/offset transaction edit with untouched-field preservation, GUI pick grouping (scripted selection stays exact), seven single-edit rejections plus a cross-entity atomic rollback, four-step undo/redo, real render linework, DWG and DXF reopen and re-edit. Erasing a leader also erases its annotation (scene leader grouping); undo restores both. **W blockers (engine, recorded as canary assertions in the test):** (1) the DXF writer never emits group 340, so a DXF save unlinks the annotation; (2) DXF reads drop `annotation_offset` (group 213 is written but not reassembled); (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (acadrust writes them only for R13–R2007 / R13–R14). Not `Complete` until the engine round-trips (1) and (2); (3) needs a decision to document or make read-only. | | MLine | Layer-only | Requires valid style and vertex fixture. | | Dimension | Layer-only | Track individual dimension subtypes. | | MultiLeader | Layer-only | Requires nested context and style fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 03ece7f70..08f8bbf33 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 22 geometry converters. Other writable properties +canvas kinds outside its 23 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 22-kind generated schema it contains only handle, kind, and layer; +outside the 23-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 10da6d43c..0c772c760 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 22 mapped kinds. +the 23 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 43b71be06..de33dc2ae 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -21,7 +21,8 @@ "Shape", "AttributeDefinition", "AttributeEntity", - "Hatch" + "Hatch", + "Leader" ], "base_fields": ["handle", "layer", "owner_handle"], "overrides": { @@ -228,6 +229,26 @@ "mpolygon_x_direction": { "exclude": true }, "mpolygon_boundary_handle_count": { "exclude": true } } + }, + "Leader": { + "fields": { + "vertices": { "required": true }, + "origin": { "read_only": true }, + "dimension_gap": { "read_only": true }, + "arrowhead_type": { "read_only": true }, + "arrow_size": { "read_only": true }, + "byblock_color": { "read_only": true }, + "dwg_unknown_bit1": { "exclude": true }, + "dwg_unknown_bit2": { "exclude": true }, + "dwg_unknown_bit3": { "exclude": true }, + "dwg_unknown_bit4": { "exclude": true }, + "dwg_unknown_bit5": { "exclude": true }, + "dwg_unknown_short1": { "exclude": true }, + "override_color": { + "exclude": true, + "_comment": "Color is a multi-payload tagged enum the generator cannot express yet. It stays snapshot-readable and is preserved untouched by Python edits." + } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 086afb826..d64cc0ab0 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4663,7 +4663,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 22); + assert_eq!(emitted.len(), 23); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index e1a152d9f..2fa714ea9 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -2152,6 +2152,222 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(hatch_handle).is_none()); } + #[test] + fn staged_python_leader_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + + dispatch(&mut host, concat!( + "PY_EVAL ocs.active_document.create_entity('Text', text='NOTE', ", + "insertion={'x':20.0,'y':10.0,'z':0.0}, height=2.5).handle" + )); + let text_handle = host.document().entities().find_map(|entity| match entity { + EntityType::Text(text) => Some(text.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create Text: {}", last_output(&host))); + let script = std::env::temp_dir().join(format!("ocs_leader_create_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "doc.create_entity('Leader', vertices=[{{'x':0.0,'y':0.0,'z':0.0}},", + "{{'x':10.0,'y':10.0,'z':0.0}},{{'x':20.0,'y':10.0,'z':0.0}}], ", + "annotation_handle={}, hookline_enabled=True)\n" + ), text_handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let leader_handle = host.document().entities().find_map(|entity| match entity { + EntityType::Leader(leader) => Some(leader.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create Leader: {}", last_output(&host))); + let created = host.document().get_entity(leader_handle).unwrap().clone(); + let EntityType::Leader(created_leader) = &created else { unreachable!() }; + assert_eq!(created_leader.annotation_handle, text_handle); + assert_eq!(created_leader.vertices.len(), 3); + + let assert_geometry = |entity: &EntityType, document: &CadDocument| { + let EntityType::Leader(leader) = entity else { panic!("expected Leader") }; + let render = leader.to_render(document).expect("leader render"); + let crate::scene::convert::acad_to_render::RenderObject::Lines(points) = render.object else { + panic!("expected leader linework"); + }; + assert!(points.len() >= leader.vertices.len()); + assert!(points.iter().flatten().all(|value| value.is_nan() || value.is_finite())); + }; + assert_geometry(&created, host.document()); + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.entities[{}].annotation_handle", leader_handle.value())); + assert!(last_output(&host).contains(&text_handle.value().to_string())); + + // GUI picking groups a leader with its annotation, while scripted + // selection stays exact. + host.app.tabs[0].scene.select_entities(&[leader_handle]); + let mut grouped = host.selection(); + grouped.sort_by_key(|handle| handle.value()); + assert_eq!(grouped, vec![text_handle, leader_handle]); + host.app.tabs[0].scene.replace_selection_exact(&[]); + + let script = std::env::temp_dir().join(format!("ocs_leader_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "leader = doc.entities[{}]\n", + "with doc.transaction('Edit leader'):\n", + " leader.vertices = [{{'x':1.0,'y':1.0,'z':0.0}},{{'x':11.0,'y':12.0,'z':0.0}},", + "{{'x':22.0,'y':12.0,'z':0.0}},{{'x':30.0,'y':12.0,'z':0.0}}]\n", + " leader.text_height = 4.0\n", + " leader.arrow_enabled = False\n", + " leader.annotation_offset = (1.0, 2.0, 0.0)\n", + "doc.selection = [leader]\n" + ), leader_handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(leader_handle).unwrap().clone(); + let EntityType::Leader(edited) = &expected else { unreachable!() }; + assert_eq!(edited.vertices.len(), 4, "edit failed: {}", last_output(&host)); + assert_eq!(edited.text_height, 4.0); + assert!(!edited.arrow_enabled); + assert_eq!(edited.annotation_offset, acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + assert_eq!(edited.annotation_handle, text_handle); + assert_eq!(edited.common, created_leader.common); + assert_eq!(edited.dimension_style, created_leader.dimension_style); + assert_eq!(edited.hookline_enabled, created_leader.hookline_enabled); + assert_eq!(edited.normal, created_leader.normal); + assert_eq!(edited.origin, created_leader.origin); + assert_eq!(edited.override_color, created_leader.override_color); + assert_eq!(host.selection(), vec![leader_handle]); + assert_geometry(&expected, host.document()); + + for (patch, message) in [ + ("'annotation_handle':999999", "does not exist"), + ("'creation_type':'WithTolerance'", "does not match creation_type"), + ("'creation_type':'NoAnnotation'", "no annotation"), + ("'vertices':[{'x':0.0,'y':0.0,'z':0.0}]", "at least 2"), + ("'text_height':0.0", "greater than zero"), + ("'dimension_style':'Missing'", "dimension style"), + ("'override_color':{}", "outside the editable schema"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject leader', [{{'handle':{}, {patch}}}])", + leader_handle.value())); + assert_eq!(host.document().get_entity(leader_handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + // A valid edit paired with an invalid one is atomic across entities. + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'text_width':5.0}},", + "{{'handle':{},'text_height':0.0}}])" + ), leader_handle.value(), text_handle.value())); + assert_eq!(host.document().get_entity(leader_handle), Some(&expected)); + + // Both formats keep the path, annotation link, arrow flag and offset. + // DWG R2010+ derives text_height/text_width/hookline_enabled instead of + // storing them (acadrust writes them for R13-R2007/R13-R14 only), so + // only DXF must round-trip those three. + let assert_saved = |document: &CadDocument, is_dxf: bool| { + let Some(EntityType::Leader(leader)) = document.get_entity(leader_handle) else { + panic!("Leader missing after reopen"); + }; + assert_eq!(leader.vertices.len(), 4); + if is_dxf { + // BLOCKER: acadrust's DXF writer never emits group 340, so a + // DXF save unlinks the annotation. Flip this to + // `assert_eq!(.., text_handle)` once the engine writes it. + assert!(leader.annotation_handle.is_null(), "acadrust now writes DXF 340; drop the blocker"); + } else { + assert_eq!(leader.annotation_handle, text_handle); + } + assert!(!leader.arrow_enabled); + if is_dxf { + // BLOCKER: the DXF writer emits group 213 but the reader's + // coordinate mapping does not reassemble it. + assert_eq!(leader.annotation_offset, acadrust::types::Vector3::ZERO, + "acadrust now reads DXF 213; drop the blocker"); + } else { + assert_eq!(leader.annotation_offset, acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + } + assert_eq!(leader.dimension_style, "Standard"); + if is_dxf { + assert!((leader.text_height - 4.0).abs() < 1e-12); + assert!(leader.hookline_enabled); + } else { + assert_eq!(leader.text_height, 2.5, "DWG R2010+ does not store text_height"); + assert!(!leader.hookline_enabled, "DWG R2010+ does not store hookline_enabled"); + } + assert!(matches!(document.get_entity(text_handle), Some(EntityType::Text(_)))); + }; + let reopened_dwg = crate::io::load_bytes("leader.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let reopened_dxf = crate::io::load_bytes("leader.dxf", + acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + assert_saved(&reopened_dwg, false); + assert_saved(&reopened_dxf, true); + for (name, mut document) in [("leader-reedit.dwg", reopened_dwg), + ("leader-reedit.dxf", reopened_dxf)] { + assert_geometry(document.get_entity(leader_handle).unwrap(), &document); + let before = document.get_entity(leader_handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::Leader(leader) = &mut after else { unreachable!() }; + leader.vertices[3] = acadrust::types::Vector3::new(35.0, 12.0, 0.0); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + *document.get_entity_mut(leader_handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(leader_handle), + Some(EntityType::Leader(leader)) if (leader.vertices[3].x - 35.0).abs() < 1e-9)); + } + + // Erasing a leader also erases its annotation (scene leader grouping). + dispatch(&mut host, &format!( + "PY_EVAL ocs.active_document.delete_entity({})", leader_handle.value())); + assert!(host.document().get_entity(leader_handle).is_none()); + assert!(host.document().get_entity(text_handle).is_none()); + for (name, bytes) in [ + ("leader-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("leader-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + let document = crate::io::load_bytes(name, bytes).unwrap(); + assert!(document.get_entity(leader_handle).is_none()); + assert!(document.get_entity(text_handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + // Text create, Leader create, edit transaction, Leader delete. + assert_eq!(app.tabs[0].history.undo_stack.len(), 4); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(leader_handle), Some(&expected)); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_some()); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(leader_handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(leader_handle).is_none()); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_some()); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none()); + app.redo_steps(4); + assert!(app.tabs[0].scene.document.get_entity(leader_handle).is_none()); + assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From a86d5bc7272572e558e9a18c36fbd9cac8005d23 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 14:02:40 +0200 Subject: [PATCH 18/76] Complete MLine host model coverage Maps vertices, flags, justification, normal, scale and style with host validation and MLineStyle binding. The host recomputes vertex direction, miter and element parameters on scripted create/edit, so scripts only supply positions. staged_python_mline_lifecycle_over_real_ipc passes the full gate including DWG and DXF reopen. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 9 + .../entity_coverage_policy.json | 4 +- crates/ocs_plugin_api/src/entity_coverage.rs | 148 ++++++++++++ docs/plugin-host-model-completion-plan.md | 11 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 19 +- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 210 +++++++++++++++++- src/entities/mline.rs | 48 ++++ 11 files changed, 445 insertions(+), 16 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index b8be77b79..277d79ea6 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -773,6 +773,15 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | "block_offset" | "annotation_offset" ) + | ( + "MLine", + "flags" + | "justification" + | "normal" + | "scale_factor" + | "style_name" + | "vertices" + ) ) { "transaction_geometry" } else { diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index a960cb236..4230aeee2 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -24,12 +24,14 @@ "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", "annotation_handle", "normal", "horizontal_direction", "block_offset", "annotation_offset"] + "Leader": ["layer", "dimension_style", "arrow_enabled", "path_type", "creation_type", "hookline_direction", "hookline_enabled", "text_height", "text_width", "vertices", "annotation_handle", "normal", "horizontal_direction", "block_offset", "annotation_offset"], + "MLine": ["layer", "flags", "justification", "normal", "scale_factor", "style_name", "vertices"] }, "readable": { "Insert": ["block_name"], "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"] }, diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 52b808a13..37ef77f55 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -318,6 +318,65 @@ fn validate_leader( Ok(()) } +/// Field checks shared by MLine creation and mutation. Vertex `direction`, +/// `miter` and segment cut parameters are derived by the host, so only their +/// finiteness is checked here. +#[cfg(feature = "host")] +fn validate_mline( + old: Option<&crate::host::acadrust::entities::MLine>, + new: &crate::host::acadrust::entities::MLine, +) -> Result<(), String> { + if old.is_none_or(|old| old.flags != new.flags) && new.flags.bits() & !0x0f != 0 { + return Err("MLine.flags has unknown bits".into()); + } + if old.is_none_or(|old| old.scale_factor.to_bits() != new.scale_factor.to_bits()) + && (!new.scale_factor.is_finite() || new.scale_factor == 0.0) + { + return Err("MLine.scale_factor must be finite and nonzero".into()); + } + if old.is_none_or(|old| old.normal != new.normal) { + solid_normal("MLine.normal", &new.normal)?; + } + if old.is_none_or(|old| old.style_name != new.style_name) && new.style_name.trim().is_empty() { + return Err("MLine.style_name is empty".into()); + } + if old.is_none_or(|old| old.vertices != new.vertices) { + if new.vertices.len() < 2 { + return Err("MLine.vertices requires at least 2 points".into()); + } + for (vi, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("MLine.vertices[{vi}].position"), &vertex.position)?; + finite_vector(&format!("MLine.vertices[{vi}].direction"), &vertex.direction)?; + finite_vector(&format!("MLine.vertices[{vi}].miter"), &vertex.miter)?; + for (si, segment) in vertex.segments.iter().enumerate() { + if segment + .parameters + .iter() + .chain(&segment.area_fill_parameters) + .any(|value| !value.is_finite()) + { + return Err(format!( + "MLine.vertices[{vi}].segments[{si}] contains non-finite parameters" + )); + } + } + } + if new + .vertices + .windows(2) + .all(|pair| pair[0].position == pair[1].position) + { + return Err("MLine.vertices must not all coincide".into()); + } + } + if new.flags.contains(crate::host::acadrust::entities::MLineFlags::CLOSED) + && new.vertices.len() < 3 + { + return Err("closed MLine requires at least 3 vertices".into()); + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -410,6 +469,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } } EntityType::Leader(value) => validate_leader(None, value)?, + EntityType::MLine(value) => validate_mline(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -764,6 +824,7 @@ pub fn validate_entity_mutation( return Err("Shape.style_handle is read-only".into()); } } + (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { changed3( @@ -1026,6 +1087,24 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::MLine(value) = entity { + let style = resolve_mline_style(document, value).ok_or_else(|| { + format!("MLine style {:?} does not exist", value.style_name) + })?; + if style.elements.is_empty() { + return Err(format!("MLine style {:?} has no elements", style.name)); + } + for (index, vertex) in value.vertices.iter().enumerate() { + if !vertex.segments.is_empty() && vertex.segments.len() != style.elements.len() { + return Err(format!( + "MLine.vertices[{index}] has {} segments but style {:?} has {} elements", + vertex.segments.len(), + style.name, + style.elements.len() + )); + } + } + } if let EntityType::Leader(value) = entity { use crate::host::acadrust::entities::LeaderCreationType; if !document @@ -1147,6 +1226,32 @@ pub fn validate_canvas_entity_references( Ok(()) } +/// Stable handle wins; the name is the fallback for legacy entities. +#[cfg(feature = "host")] +fn resolve_mline_style<'a>( + document: &'a crate::host::CadDocument, + mline: &crate::host::acadrust::entities::MLine, +) -> Option<&'a crate::host::acadrust::objects::MLineStyle> { + use crate::host::acadrust::objects::ObjectType; + mline + .style_handle + .filter(|handle| !handle.is_null()) + .and_then(|handle| match document.objects.get(&handle) { + Some(ObjectType::MLineStyle(style)) => Some(style), + _ => None, + }) + .or_else(|| { + document.objects.values().find_map(|object| match object { + ObjectType::MLineStyle(style) + if style.name.eq_ignore_ascii_case(mline.style_name.trim()) => + { + Some(style) + } + _ => None, + }) + }) +} + /// Fill name-based table references with stable handles before a new entity or /// replacement is committed. Existing non-null handles remain authoritative /// for legacy DWG entities whose fallback name can be stale. @@ -1173,6 +1278,9 @@ pub fn bind_canvas_entity_references( }) .map(|style| style.handle); } + EntityType::MLine(value) if value.style_handle.filter(|h| !h.is_null()).is_none() => { + value.style_handle = resolve_mline_style(document, value).map(|style| style.handle); + } EntityType::Shape(value) if value.style_handle.filter(|h| !h.is_null()).is_none() => { value.style_handle = document .text_styles @@ -1334,6 +1442,7 @@ mod tests { | "AttributeEntity" | "Hatch" | "Leader" + | "MLine" ) { assert!( @@ -1482,6 +1591,12 @@ mod tests { "Leader.horizontal_direction".to_owned(), "Leader.block_offset".to_owned(), "Leader.annotation_offset".to_owned(), + "MLine.flags".to_owned(), + "MLine.justification".to_owned(), + "MLine.normal".to_owned(), + "MLine.scale_factor".to_owned(), + "MLine.style_name".to_owned(), + "MLine.vertices".to_owned(), ]) ); } @@ -1577,6 +1692,39 @@ mod tests { .contains("finite")); } + #[cfg(feature = "host")] + #[test] + fn mline_geometry_and_style_references_are_validated_and_bound() { + use crate::host::acadrust::{self, entities::MLineVertex, types::Vector3}; + let document = acadrust::CadDocument::new(); + let mut mline = acadrust::entities::MLine::new(); + for x in [0.0, 5.0] { + mline.vertices.push(MLineVertex::new(Vector3::new(x, 0.0, 0.0))); + } + let mut entity = acadrust::EntityType::MLine(mline.clone()); + validate_new_canvas_entity(&entity).unwrap(); + bind_canvas_entity_references(&document, &mut entity).unwrap(); + assert!(matches!(&entity, acadrust::EntityType::MLine(value) if value.style_handle.is_some())); + + let mut missing = mline.clone(); + missing.style_name = "Missing".into(); + assert!(bind_canvas_entity_references(&document, &mut acadrust::EntityType::MLine(missing)) + .unwrap_err().contains("does not exist")); + let mut wrong_segments = mline.clone(); + wrong_segments.vertices[0].init_segments(3); + assert!(bind_canvas_entity_references(&document, &mut acadrust::EntityType::MLine(wrong_segments)) + .unwrap_err().contains("segments but style")); + let mut one = mline.clone(); + one.vertices.truncate(1); + assert!(validate_new_canvas_entity(&acadrust::EntityType::MLine(one)).unwrap_err().contains("at least 2")); + let mut closed = mline.clone(); + closed.flags |= acadrust::entities::MLineFlags::CLOSED; + assert!(validate_new_canvas_entity(&acadrust::EntityType::MLine(closed)).unwrap_err().contains("at least 3")); + let mut scale = mline.clone(); + scale.scale_factor = 0.0; + assert!(validate_new_canvas_entity(&acadrust::EntityType::MLine(scale)).unwrap_err().contains("nonzero")); + } + #[cfg(feature = "host")] #[test] fn shape_geometry_and_shape_file_style_are_validated_and_bound() { diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 95f95931d..950f6fa49 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **23 of 43** canvas kinds: 22 -are creatable and `Insert` remains update-only. This is **not** 23 completed -kinds: Tolerance, Shape, AttributeEntity and Hatch pass every completion gate. +v7. The Python feature has explicit writes for **24 of 43** canvas kinds: 23 +are creatable and `Insert` remains update-only. This is **not** 24 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch and MLine pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 20 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 19 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **MLine** (queue item 2). Leader is done except for its W +Continue with **Dimension** (queue item 3; track each subtype separately). Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -158,3 +158,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `Hatch` with full solid/pattern definitions and typed nested boundary paths. The adapter generator now handles single-payload tagged enums as `{kind, value}` dictionaries, which covers every Hatch edge variant and is reusable by later nested entity models. Unknown adapter properties are rejected instead of being silently ignored. - `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with closed line-loop fixtures: solid creation, conversion to a stored-line pattern, boundary replacement, selection and live render-cache checks, atomic invalid scale/association/open-loop/unmapped-payload rejection, edited/deleted DWG and DXF reopen, post-reopen edits, and three-step undo/redo. Hatch is **Complete**; the queue now continues with `Leader`. - Mapped `Leader` (host validation and reference binding in `entity_coverage.rs`, policy/build.rs/manifest entries, adapter count 23). `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P through the real runner. W is blocked by acadrust: the DXF writer omits the annotation handle (340), DXF reads lose `annotation_offset` (213), and DWG R2010+ does not store text size or hookline flag. Each gap is pinned by a canary assertion so it fails loudly once fixed. The queue now continues with `MLine`. +- Completed `MLine`. Host validation and style binding live in `ocs_plugin_api`; derived vertex geometry and element parameters are recomputed by the host (`normalize_scripted_mline`) on scripted create/edit, so scripts supply positions only. The adapter maps `flags` as an integer through a manifest override because the generator cannot classify acadrust's serde-newtype `MLineFlags`. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen. The queue now continues with `Dimension`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 9e8946b1c..93d8b2189 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **23 of 43 canvas kinds have explicit geometry writes** (22 creatable, plus update-only `Insert`), **4 kinds are complete**, and 20 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **24 of 43 canvas kinds have explicit geometry writes** (23 creatable, plus update-only `Insert`), **5 kinds are complete**, and 19 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -29,7 +29,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | AttributeEntity | **Complete** | OCS commit `cf2a9235` and adapter commit `9c6b5c3` make nested ATTRIB records addressable by handle while retaining canonical storage in `Insert.attributes`. Host validation binds tag to a definition in the Insert's block, validates placement/text geometry and style references, keeps owner and definition handles read-only, and preserves embedded MTEXT. `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a real block, ATTDEF and Insert: high-level create/read/transaction edit/delete, parent selection, rendered text, atomic invalid tag/height/owner rejection, edited and deleted DWG/DXF reopen, and three-step undo/redo. `nested_insert_attribute_crud_uses_parent_storage_and_undo` additionally proves the child never becomes an orphan flat entity in the host document. | | Hatch | **Complete** | OCS commit `98b56ef6` and adapter commit `a9b5de8` expose solid and patterned Hatches with elevation/normal, complete pattern definitions, pattern controls, style, association state, boundary paths and seed points. Nested tagged boundary edges use the reusable `{kind, value}` adapter form. Host validation requires closed finite loops, validates line/arc/ellipse/polyline/spline payloads and stored pattern lines, resolves associative boundary handles, and preserves gradient and MPOLYGON payloads as explicitly unsupported. `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: Python solid creation, patterned boundary edit, live render-model pattern/boundary checks, selection, atomic invalid scale/association/open-loop/unmapped-property rejection, edited and deleted DWG/DXF reopen, post-reopen edits, and three-step undo/redo. | | Leader | **Integration-tested / blocked at W by acadrust** | Fields mapped writable: `vertices` (≥2 finite points, not all coincident), `annotation_handle`, `creation_type`, `path_type`, `dimension_style`, `arrow_enabled`, `hookline_enabled`, `hookline_direction`, `text_height`, `text_width`, `normal`, `horizontal_direction`, `block_offset`, `annotation_offset`. Read-only: `origin`, `dimension_gap`, `arrowhead_type`, `arrow_size`, `byblock_color`. Unmapped but preserved on edit: `override_color` (multi-payload `Color` enum the generator cannot express) and the `dwg_unknown_*` fields. Host reference binding validates that `annotation_handle` exists, is not the leader itself, matches `creation_type` (Text/MText, Tolerance, Insert; `NoAnnotation` forbids a handle) and that `dimension_style` exists; nothing is silently defaulted. `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P against acadrust `7ea4247`: linked Text annotation, path/height/arrow/offset transaction edit with untouched-field preservation, GUI pick grouping (scripted selection stays exact), seven single-edit rejections plus a cross-entity atomic rollback, four-step undo/redo, real render linework, DWG and DXF reopen and re-edit. Erasing a leader also erases its annotation (scene leader grouping); undo restores both. **W blockers (engine, recorded as canary assertions in the test):** (1) the DXF writer never emits group 340, so a DXF save unlinks the annotation; (2) DXF reads drop `annotation_offset` (group 213 is written but not reassembled); (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (acadrust writes them only for R13–R2007 / R13–R14). Not `Complete` until the engine round-trips (1) and (2); (3) needs a decision to document or make read-only. | -| MLine | Layer-only | Requires valid style and vertex fixture. | +| MLine | **Complete** | Writable: `vertices` (raw vertex dicts; a script may pass only `position`), `flags` (integer, unknown bits rejected), `justification`, `normal`, `scale_factor`, `style_name`. Read-only: `start_point`, `style_handle`, `style_element_count`. Host validation requires ≥2 finite non-coincident vertices, ≥3 for a closed multiline, a finite nonzero scale and normal, an existing non-empty `MLineStyle`, and per-vertex segments that are either empty or match the style element count. Vertex `direction`/`miter`, segment layout and per-element miter offsets (justification and scale included) are host-derived by `normalize_scripted_mline` on every scripted create or edit, reusing the drawing command's `rebuild_mline_geometry`/`sync_mline_element_parameters`; dash and fill cuts survive an edit unless a vertex moves. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: create against the Standard style, a three-vertex/scale/justification transaction edit with untouched-field preservation, corner miter widening and element offsets checked, real `mline_lines` linework before and after DWG and DXF reopen, nine single-edit rejections plus a cross-entity atomic rollback, re-edit after reopen in both formats, deletion, and three-step undo/redo. Known limit: switching to a style with a different element count is rejected until the script also clears the vertices' `segments`; only the rejection is covered by the integration test. | | Dimension | Layer-only | Track individual dimension subtypes. | | MultiLeader | Layer-only | Requires nested context and style fixture. | | Table | Layer-only | Requires table style and cell fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 08f8bbf33..3dad4fbe7 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 23 geometry converters. Other writable properties +canvas kinds outside its 24 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 23-kind generated schema it contains only handle, kind, and layer; +outside the 24-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 0c772c760..edd6e405b 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 23 mapped kinds. +the 24 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index de33dc2ae..93da7a608 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -22,7 +22,8 @@ "AttributeDefinition", "AttributeEntity", "Hatch", - "Leader" + "Leader", + "MLine" ], "base_fields": ["handle", "layer", "owner_handle"], "overrides": { @@ -230,6 +231,22 @@ "mpolygon_boundary_handle_count": { "exclude": true } } }, + "MLine": { + "fields": { + "vertices": { "required": true }, + "flags": { + "rust_getter": "(VAR.flags.bits() as i64)", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"flags\", vm)? { let bits = v.try_into_value::(vm)?; VAR.flags = i16::try_from(bits).ok().and_then(acadrust::entities::MLineFlags::from_bits).ok_or_else(|| vm.new_value_error(\"MLine.flags has unknown bits\".to_owned()))?; }" + }, + "start_point": { "read_only": true }, + "style_handle": { "read_only": true }, + "style_element_count": { "read_only": true }, + "style_name": { + "rust_getter": "VAR.style_name.clone()", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"style_name\", vm)? { VAR.style_name = v.try_into_value::(vm)?; VAR.style_handle = None; }" + } + } + }, "Leader": { "fields": { "vertices": { "required": true }, diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index d64cc0ab0..a1e3c1448 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4663,7 +4663,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 23); + assert_eq!(emitted.len(), 24); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 2fa714ea9..67c7fedce 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -149,7 +149,26 @@ impl<'a> HostSession<'a> { } } - pub fn add_entity(&mut self, entity: EntityType) -> Handle { + /// Recompute host-derived state for entities a script created or edited. + fn normalize_scripted_entity( + &self, + old: Option<&EntityType>, + entity: &mut EntityType, + ) -> Result<(), String> { + if let EntityType::MLine(new) = entity { + let old = match old { + Some(EntityType::MLine(old)) => Some(old), + _ => None, + }; + crate::entities::mline::normalize_scripted_mline(old, new, self.document())?; + } + Ok(()) + } + + pub fn add_entity(&mut self, mut entity: EntityType) -> Handle { + if self.normalize_scripted_entity(None, &mut entity).is_err() { + return Handle::NULL; + } if let EntityType::AttributeEntity(mut attribute) = entity { let owner = attribute.common.owner_handle; let Some(EntityType::Insert(mut insert)) = self.document().get_entity(owner).cloned() @@ -182,7 +201,12 @@ impl<'a> HostSession<'a> { handle } - pub fn add_entities(&mut self, entities: Vec) -> Vec { + pub fn add_entities(&mut self, mut entities: Vec) -> Vec { + for entity in &mut entities { + // Creation input was validated by the caller; a degenerate MLine + // simply keeps its supplied (unnormalized) vertices. + let _ = self.normalize_scripted_entity(None, entity); + } let handles = self.app.tabs[self.tab].scene.add_entities(entities); self.publish_document_view(); handles @@ -194,7 +218,11 @@ impl<'a> HostSession<'a> { /// Replace the entity carrying `entity`'s handle in place, refreshing the /// scene's derived caches. Returns `false` when no entity has that handle. - pub fn update_entity(&mut self, entity: EntityType) -> bool { + pub fn update_entity(&mut self, mut entity: EntityType) -> bool { + let old = self.document().get_entity(entity.common().handle).cloned(); + if self.normalize_scripted_entity(old.as_ref(), &mut entity).is_err() { + return false; + } let ok = match entity { EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), entity => self.app.tabs[self.tab].scene.update_entity(entity), @@ -236,6 +264,8 @@ impl<'a> HostSession<'a> { .map_err(|error| format!("entity {handle:?}: {error}"))?; ocs_plugin_api::entity_coverage::validate_entity_mutation(&existing, entity) .map_err(|error| format!("entity {handle:?}: {error}"))?; + self.normalize_scripted_entity(Some(&existing), entity) + .map_err(|error| format!("entity {handle:?}: {error}"))?; let lock_handle = if matches!(entity, EntityType::AttributeEntity(_)) { entity.common().owner_handle } else { @@ -2368,6 +2398,180 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(text_handle).is_none()); } + #[test] + fn staged_python_mline_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let style_handle = host.document().objects.iter().find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::MLineStyle(style) if style.name == "Standard" => Some(*handle), + _ => None, + }).expect("document carries the Standard MLineStyle"); + + let script = std::env::temp_dir().join(format!("ocs_mline_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "def p(x, y): return {'position': {'x': x, 'y': y, 'z': 0.0}}\n", + "doc = ocs.active_document\n", + "doc.create_entity('MLine', vertices=[p(0.0, 0.0), p(10.0, 0.0)])\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let handle = host.document().entities().find_map(|entity| match entity { + EntityType::MLine(mline) => Some(mline.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create MLine: {}", last_output(&host))); + let created = host.document().get_entity(handle).unwrap().clone(); + let EntityType::MLine(created_mline) = &created else { unreachable!() }; + assert_eq!(created_mline.style_handle, Some(style_handle)); + assert_eq!(created_mline.style_element_count, 2); + assert_eq!(created_mline.start_point, acadrust::types::Vector3::ZERO); + let first_parameters = |mline: &acadrust::entities::MLine, vertex: usize| -> Vec { + mline.vertices[vertex].segments.iter().map(|segment| segment.parameters[0]).collect() + }; + assert_eq!(first_parameters(created_mline, 0), vec![0.5, -0.5]); + assert!(created_mline.vertices.iter().all(|vertex| { + (vertex.direction.x - 1.0).abs() < 1e-9 && (vertex.miter.y.abs() - 1.0).abs() < 1e-9 + })); + let assert_lines = |entity: &EntityType, document: &CadDocument, expected_vertices: usize| { + let EntityType::MLine(mline) = entity else { panic!("expected MLine") }; + assert_eq!(mline.vertices.len(), expected_vertices); + let lines = crate::entities::mline::mline_lines(mline, document); + assert!(lines.len() >= 2, "expected styled element lines"); + assert!(lines.iter().flat_map(|line| &line.points).flatten().all(|v| v.is_finite())); + }; + assert_lines(&created, host.document(), 2); + + let script = std::env::temp_dir().join(format!("ocs_mline_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def p(x, y): return {{'position': {{'x': x, 'y': y, 'z': 0.0}}}}\n", + "doc = ocs.active_document\n", + "mline = doc.entities[{}]\n", + "with doc.transaction('Edit mline'):\n", + " mline.vertices = [p(0.0, 0.0), p(10.0, 0.0), p(10.0, 10.0)]\n", + " mline.scale_factor = 2.0\n", + " mline.justification = 'Top'\n", + "doc.selection = [mline]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::MLine(edited) = &expected else { unreachable!() }; + assert_eq!(edited.vertices.len(), 3, "edit failed: {}", last_output(&host)); + assert_eq!(edited.scale_factor, 2.0); + assert_eq!(edited.justification, acadrust::entities::MLineJustification::Top); + assert_eq!(edited.style_handle, created_mline.style_handle); + assert_eq!(edited.common, created_mline.common); + assert_eq!(edited.normal, created_mline.normal); + assert_eq!(edited.flags.bits() & !1, 0); + // Top justification with scale 2: first element sits on the path, + // the second 2.0 to the right of travel; the corner miter widens it. + let end = first_parameters(edited, 0); + assert!(end[0].abs() < 1e-9 && (end[1] + 2.0).abs() < 1e-9, "{end:?}"); + let corner = first_parameters(edited, 1); + assert!(corner[1].abs() > 2.0 && corner[1].is_finite(), "miter must widen at the corner: {corner:?}"); + assert!((edited.vertices[1].direction.y - edited.vertices[1].direction.x).abs() > 1e-6); + assert_lines(&expected, host.document(), 3); + assert_eq!(host.selection(), vec![handle]); + + for (patch, message) in [ + ("'style_name':'Missing'", "does not exist"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'scale_factor':0.0", "nonzero"), + ("'flags':64", "unknown bits"), + ("'flags':3,'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "requires at least 3"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "coincide"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':float('nan'),'z':0.0}}]", "finite"), + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0},'segments':[{}]},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "segments but style"), + ("'start_point':{'x':5.0,'y':5.0,'z':0.0}", "read-only"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject mline', [{{'handle':{}, {patch}}}])", handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'scale_factor':3.0}},", + "{{'handle':{},'scale_factor':0.0}}])" + ), handle.value(), handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + + let assert_saved = |document: &CadDocument| { + let Some(EntityType::MLine(mline)) = document.get_entity(handle) else { + panic!("MLine missing after reopen"); + }; + assert_eq!(mline.vertices.len(), 3); + assert_eq!(mline.scale_factor, 2.0); + assert_eq!(mline.justification, acadrust::entities::MLineJustification::Top); + assert_eq!(mline.style_name, "Standard"); + assert_eq!(mline.vertices[2].position, acadrust::types::Vector3::new(10.0, 10.0, 0.0)); + let (a, b) = (first_parameters(mline, 1), first_parameters(edited, 1)); + assert!(a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-9), "{a:?} vs {b:?}"); + assert_lines(&document.get_entity(handle).unwrap().clone(), document, 3); + }; + let reopened_dwg = crate::io::load_bytes("mline.dwg", + acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let reopened_dxf = crate::io::load_bytes("mline.dxf", + acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + assert_saved(&reopened_dwg); + assert_saved(&reopened_dxf); + for (name, mut document) in [("mline-reedit.dwg", reopened_dwg), ("mline-reedit.dxf", reopened_dxf)] { + let before = document.get_entity(handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::MLine(mline) = &mut after else { unreachable!() }; + mline.vertices[2].position = acadrust::types::Vector3::new(10.0, 12.0, 0.0); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + let EntityType::MLine(mline) = &mut after else { unreachable!() }; + let old = match &before { EntityType::MLine(old) => old, _ => unreachable!() }; + crate::entities::mline::normalize_scripted_mline(Some(old), mline, &document).unwrap(); + *document.get_entity_mut(handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle), + Some(EntityType::MLine(mline)) if (mline.vertices[2].position.y - 12.0).abs() < 1e-9)); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + for (name, bytes) in [ + ("mline-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("mline-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { diff --git a/src/entities/mline.rs b/src/entities/mline.rs index f9d10c7ce..e2ead2af9 100644 --- a/src/entities/mline.rs +++ b/src/entities/mline.rs @@ -418,6 +418,54 @@ fn restore_shifted_mline_segment( } } +/// Recompute the derived MLine state after a scripted create or edit: +/// vertex direction/miter, element segment layout and the per-element miter +/// offsets. Dash/cut parameters survive an edit unless a vertex moved. +pub(crate) fn normalize_scripted_mline( + old: Option<&MLine>, + new: &mut MLine, + document: &acadrust::CadDocument, +) -> Result<(), String> { + let style = resolved_mline_style(new, document) + .ok_or_else(|| format!("MLine style {:?} does not exist", new.style_name))?; + let unchanged = old.is_some_and(|old| { + old.vertices == new.vertices + && old.normal == new.normal + && old.flags == new.flags + && old.justification == new.justification + && old.scale_factor.to_bits() == new.scale_factor.to_bits() + && old.style_name == new.style_name + && old.style_handle == new.style_handle + }); + if unchanged { + return Ok(()); + } + let moved = old.is_none_or(|old| { + old.vertices.len() != new.vertices.len() + || old + .vertices + .iter() + .zip(&new.vertices) + .any(|(a, b)| a.position != b.position) + }); + let elements = style.elements.len().max(1); + for vertex in &mut new.vertices { + if vertex.segments.is_empty() { + vertex.init_segments(elements); + } else if moved { + for segment in &mut vertex.segments { + segment.parameters.truncate(1); + segment.area_fill_parameters.clear(); + } + } + } + if !rebuild_mline_geometry(new) { + return Err("MLine vertices are degenerate".into()); + } + crate::modules::draw::draw::mline::sync_mline_element_parameters(new, style); + Ok(()) +} + /// Resolve a multiline into styled parallel lines in WCS. pub fn resolved_mline_style<'a>( m: &MLine, From 6b4c86fd7a4bee3683d16c70abd2b0537c1587aa Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 14:15:03 +0200 Subject: [PATCH 19/76] Complete Dimension host model coverage for all nine subtypes Adds a hand-written flat Dimension model to the adapter (manifest manual_kinds hook) and a synthetic-property section to the host coverage policy. The host derives actual_measurement and the base definition point on scripted create/edit. staged_python_dimension_lifecycle_over_real_ipc passes the full gate for every subtype including DWG and DXF reopen. Python ints are now accepted for coordinates and angles. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 24 + .../entity_coverage_policy.json | 554 ++++++++++++++++-- crates/ocs_plugin_api/src/entity_coverage.rs | 131 +++++ docs/plugin-host-model-completion-plan.md | 11 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/build/generate.rs | 28 + plugins/opencad-python/entity_manifest.json | 4 +- .../opencad-python/src/dimension_model.rs.inc | 327 +++++++++++ plugins/opencad-python/src/ocs_module.rs | 27 +- src/app/plugin_host.rs | 206 +++++++ src/entities/dimension.rs | 8 + 13 files changed, 1281 insertions(+), 49 deletions(-) create mode 100644 plugins/opencad-python/src/dimension_model.rs.inc diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 277d79ea6..77623fd0e 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -21,6 +21,13 @@ 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, @@ -28,6 +35,10 @@ struct EntityCoveragePolicy { editable: BTreeMap>, readable: BTreeMap>, aliases: BTreeMap>, + /// 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>, } fn main() { @@ -802,6 +813,19 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { 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!(".{}", 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:?}" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 4230aeee2..b60db1e29 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -1,44 +1,526 @@ { - "internal_kinds": ["Block", "BlockEnd", "Seqend"], - "opaque_kinds": ["Extended", "Unknown"], + "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", "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", "annotation_handle", "normal", "horizontal_direction", "block_offset", "annotation_offset"], - "MLine": ["layer", "flags", "justification", "normal", "scale_factor", "style_name", "vertices"] + "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", + "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", + "annotation_handle", + "normal", + "horizontal_direction", + "block_offset", + "annotation_offset" + ], + "MLine": [ + "layer", + "flags", + "justification", + "normal", + "scale_factor", + "style_name", + "vertices" + ] }, "readable": { - "Insert": ["block_name"], - "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"] + "Insert": [ + "block_name" + ], + "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" + ] }, "aliases": { - "Polyline": {"closed": "flags"}, - "Polyline2D": {"closed": "flags"}, - "Text": {"text": "value", "insertion": "insertion_point"}, - "MText": {"text": "value", "insertion": "insertion_point"} + "Polyline": { + "closed": "flags" + }, + "Polyline2D": { + "closed": "flags" + }, + "Text": { + "text": "value", + "insertion": "insertion_point" + }, + "MText": { + "text": "value", + "insertion": "insertion_point" + } + }, + "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" + } + ] } } diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 37ef77f55..51c268545 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -377,6 +377,94 @@ fn validate_mline( Ok(()) } +/// Field checks for every Dimension subtype. Derived fields (base definition +/// point, `actual_measurement`) are recomputed by the host, so they are not +/// validated as inputs; the measurement the geometry implies must be finite. +#[cfg(feature = "host")] +fn validate_dimension( + old: Option<&crate::host::acadrust::entities::Dimension>, + new: &crate::host::acadrust::entities::Dimension, +) -> Result<(), String> { + use crate::host::acadrust::entities::Dimension as D; + if old == Some(new) { + return Ok(()); + } + let base = new.base(); + solid_normal("Dimension.normal", &base.normal)?; + finite_vector("Dimension.text_middle_point", &base.text_middle_point)?; + if !base.text_rotation.is_finite() || !base.horizontal_direction.is_finite() { + return Err("Dimension.text_rotation and horizontal_direction must be finite".into()); + } + if base.style_name.trim().is_empty() { + return Err("Dimension.style_name is empty".into()); + } + let (points, scalars, distinct): (Vec<(&str, &crate::host::acadrust::types::Vector3)>, Vec<(&str, f64)>, Vec<(&str, &str)>) = match new { + D::Aligned(d) => ( + vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point)], + vec![("ext_line_rotation", d.ext_line_rotation)], + vec![("first_point", "second_point")], + ), + D::Linear(d) => ( + vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point)], + vec![("rotation", d.rotation), ("ext_line_rotation", d.ext_line_rotation)], + vec![("first_point", "second_point")], + ), + D::Radius(d) => ( + vec![("definition_point", &d.definition_point), ("angle_vertex", &d.angle_vertex)], + vec![("leader_length", d.leader_length)], + vec![("definition_point", "angle_vertex")], + ), + D::Diameter(d) => ( + vec![("definition_point", &d.definition_point), ("angle_vertex", &d.angle_vertex)], + vec![("leader_length", d.leader_length)], + vec![("definition_point", "angle_vertex")], + ), + D::Angular2Ln(d) => ( + vec![("definition_point", &d.definition_point), ("dimension_arc", &d.dimension_arc), ("first_point", &d.first_point), ("second_point", &d.second_point), ("angle_vertex", &d.angle_vertex)], + vec![], + vec![], + ), + D::Angular3Pt(d) => ( + vec![("definition_point", &d.definition_point), ("first_point", &d.first_point), ("second_point", &d.second_point), ("angle_vertex", &d.angle_vertex)], + vec![], + vec![("first_point", "angle_vertex"), ("second_point", "angle_vertex")], + ), + D::Ordinate(d) => ( + vec![("definition_point", &d.definition_point), ("feature_location", &d.feature_location), ("leader_endpoint", &d.leader_endpoint)], + vec![], + vec![], + ), + D::Arc(d) => ( + vec![("definition_point", &d.definition_point), ("first_extension_point", &d.first_extension_point), ("second_extension_point", &d.second_extension_point), ("center_point", &d.center_point), ("first_leader_point", &d.first_leader_point), ("second_leader_point", &d.second_leader_point)], + vec![("arc_start_parameter", d.arc_start_parameter), ("arc_end_parameter", d.arc_end_parameter)], + vec![("first_extension_point", "second_extension_point")], + ), + D::LargeRadial(d) => ( + vec![("definition_point", &d.definition_point), ("chord_point", &d.chord_point), ("override_center", &d.override_center), ("jog_point", &d.jog_point)], + vec![("jog_angle", d.jog_angle)], + vec![("definition_point", "chord_point")], + ), + }; + for (name, point) in &points { + finite_vector(&format!("Dimension.{name}"), point)?; + } + for (name, value) in &scalars { + if !value.is_finite() { + return Err(format!("Dimension.{name} must be finite")); + } + } + let find = |name: &str| points.iter().find(|(n, _)| *n == name).map(|(_, p)| **p); + for (a, b) in distinct { + if find(a) == find(b) { + return Err(format!("Dimension.{a} and {b} must not coincide")); + } + } + if !new.measurement().is_finite() { + return Err("Dimension geometry does not define a finite measurement".into()); + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -470,6 +558,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } EntityType::Leader(value) => validate_leader(None, value)?, EntityType::MLine(value) => validate_mline(None, value)?, + EntityType::Dimension(value) => validate_dimension(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -824,6 +913,7 @@ pub fn validate_entity_mutation( return Err("Shape.style_handle is read-only".into()); } } + (EntityType::Dimension(old), EntityType::Dimension(new)) => validate_dimension(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -1087,6 +1177,12 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::Dimension(value) = entity { + let name = value.base().style_name.trim(); + if !document.dim_styles.iter().any(|style| style.name.eq_ignore_ascii_case(name)) { + return Err(format!("Dimension style {:?} does not exist", value.base().style_name)); + } + } if let EntityType::MLine(value) = entity { let style = resolve_mline_style(document, value).ok_or_else(|| { format!("MLine style {:?} does not exist", value.style_name) @@ -1443,6 +1539,7 @@ mod tests { | "Hatch" | "Leader" | "MLine" + | "Dimension" ) { assert!( @@ -1597,6 +1694,40 @@ mod tests { "MLine.scale_factor".to_owned(), "MLine.style_name".to_owned(), "MLine.vertices".to_owned(), + "Dimension.text".to_owned(), + "Dimension.style_name".to_owned(), + "Dimension.normal".to_owned(), + "Dimension.text_middle_point".to_owned(), + "Dimension.attachment_point".to_owned(), + "Dimension.text_rotation".to_owned(), + "Dimension.horizontal_direction".to_owned(), + "Dimension.flip_arrow1".to_owned(), + "Dimension.flip_arrow2".to_owned(), + "Dimension.text_user_positioned".to_owned(), + "Dimension.definition_point".to_owned(), + "Dimension.first_point".to_owned(), + "Dimension.second_point".to_owned(), + "Dimension.angle_vertex".to_owned(), + "Dimension.dimension_arc".to_owned(), + "Dimension.feature_location".to_owned(), + "Dimension.leader_endpoint".to_owned(), + "Dimension.first_extension_point".to_owned(), + "Dimension.second_extension_point".to_owned(), + "Dimension.center_point".to_owned(), + "Dimension.first_leader_point".to_owned(), + "Dimension.second_leader_point".to_owned(), + "Dimension.chord_point".to_owned(), + "Dimension.override_center".to_owned(), + "Dimension.jog_point".to_owned(), + "Dimension.rotation".to_owned(), + "Dimension.ext_line_rotation".to_owned(), + "Dimension.leader_length".to_owned(), + "Dimension.arc_start_parameter".to_owned(), + "Dimension.arc_end_parameter".to_owned(), + "Dimension.jog_angle".to_owned(), + "Dimension.is_ordinate_type_x".to_owned(), + "Dimension.is_partial".to_owned(), + "Dimension.has_leader".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 950f6fa49..049a4db14 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **24 of 43** canvas kinds: 23 -are creatable and `Insert` remains update-only. This is **not** 24 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch and MLine pass every completion gate. +v7. The Python feature has explicit writes for **25 of 43** canvas kinds: 24 +are creatable and `Insert` remains update-only. This is **not** 25 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine and Dimension pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 19 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 18 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **Dimension** (queue item 3; track each subtype separately). Leader is done except for its W +Continue with **MultiLeader** (queue item 4). Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -159,3 +159,4 @@ exact queue above and update statuses with evidence after each increment. - `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with closed line-loop fixtures: solid creation, conversion to a stored-line pattern, boundary replacement, selection and live render-cache checks, atomic invalid scale/association/open-loop/unmapped-payload rejection, edited/deleted DWG and DXF reopen, post-reopen edits, and three-step undo/redo. Hatch is **Complete**; the queue now continues with `Leader`. - Mapped `Leader` (host validation and reference binding in `entity_coverage.rs`, policy/build.rs/manifest entries, adapter count 23). `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P through the real runner. W is blocked by acadrust: the DXF writer omits the annotation handle (340), DXF reads lose `annotation_offset` (213), and DWG R2010+ does not store text size or hookline flag. Each gap is pinned by a canary assertion so it fails loudly once fixed. The queue now continues with `MLine`. - Completed `MLine`. Host validation and style binding live in `ocs_plugin_api`; derived vertex geometry and element parameters are recomputed by the host (`normalize_scripted_mline`) on scripted create/edit, so scripts supply positions only. The adapter maps `flags` as an integer through a manifest override because the generator cannot classify acadrust's serde-newtype `MLineFlags`. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen. The queue now continues with `Dimension`. +- Completed `Dimension` for all nine subtypes. Because the payload is an enum, the adapter generator gained a `manual_kinds` hook (hand-written `_to_dict/_from_dict/_apply`) and the host catalog a `synthetic` policy section. The host derives `actual_measurement` and the base definition point on every scripted create/edit. Python ints are now accepted wherever a coordinate or angle is expected. `staged_python_dimension_lifecycle_over_real_ipc` records each subtype separately and all nine pass C/R/E/D/U/I/W/V/P. The queue now continues with `MultiLeader`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 93d8b2189..0118f6573 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **24 of 43 canvas kinds have explicit geometry writes** (23 creatable, plus update-only `Insert`), **5 kinds are complete**, and 19 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **25 of 43 canvas kinds have explicit geometry writes** (24 creatable, plus update-only `Insert`), **6 kinds are complete**, and 18 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -30,7 +30,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Hatch | **Complete** | OCS commit `98b56ef6` and adapter commit `a9b5de8` expose solid and patterned Hatches with elevation/normal, complete pattern definitions, pattern controls, style, association state, boundary paths and seed points. Nested tagged boundary edges use the reusable `{kind, value}` adapter form. Host validation requires closed finite loops, validates line/arc/ellipse/polyline/spline payloads and stored pattern lines, resolves associative boundary handles, and preserves gradient and MPOLYGON payloads as explicitly unsupported. `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: Python solid creation, patterned boundary edit, live render-model pattern/boundary checks, selection, atomic invalid scale/association/open-loop/unmapped-property rejection, edited and deleted DWG/DXF reopen, post-reopen edits, and three-step undo/redo. | | Leader | **Integration-tested / blocked at W by acadrust** | Fields mapped writable: `vertices` (≥2 finite points, not all coincident), `annotation_handle`, `creation_type`, `path_type`, `dimension_style`, `arrow_enabled`, `hookline_enabled`, `hookline_direction`, `text_height`, `text_width`, `normal`, `horizontal_direction`, `block_offset`, `annotation_offset`. Read-only: `origin`, `dimension_gap`, `arrowhead_type`, `arrow_size`, `byblock_color`. Unmapped but preserved on edit: `override_color` (multi-payload `Color` enum the generator cannot express) and the `dwg_unknown_*` fields. Host reference binding validates that `annotation_handle` exists, is not the leader itself, matches `creation_type` (Text/MText, Tolerance, Insert; `NoAnnotation` forbids a handle) and that `dimension_style` exists; nothing is silently defaulted. `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P against acadrust `7ea4247`: linked Text annotation, path/height/arrow/offset transaction edit with untouched-field preservation, GUI pick grouping (scripted selection stays exact), seven single-edit rejections plus a cross-entity atomic rollback, four-step undo/redo, real render linework, DWG and DXF reopen and re-edit. Erasing a leader also erases its annotation (scene leader grouping); undo restores both. **W blockers (engine, recorded as canary assertions in the test):** (1) the DXF writer never emits group 340, so a DXF save unlinks the annotation; (2) DXF reads drop `annotation_offset` (group 213 is written but not reassembled); (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (acadrust writes them only for R13–R2007 / R13–R14). Not `Complete` until the engine round-trips (1) and (2); (3) needs a decision to document or make read-only. | | MLine | **Complete** | Writable: `vertices` (raw vertex dicts; a script may pass only `position`), `flags` (integer, unknown bits rejected), `justification`, `normal`, `scale_factor`, `style_name`. Read-only: `start_point`, `style_handle`, `style_element_count`. Host validation requires ≥2 finite non-coincident vertices, ≥3 for a closed multiline, a finite nonzero scale and normal, an existing non-empty `MLineStyle`, and per-vertex segments that are either empty or match the style element count. Vertex `direction`/`miter`, segment layout and per-element miter offsets (justification and scale included) are host-derived by `normalize_scripted_mline` on every scripted create or edit, reusing the drawing command's `rebuild_mline_geometry`/`sync_mline_element_parameters`; dash and fill cuts survive an edit unless a vertex moves. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: create against the Standard style, a three-vertex/scale/justification transaction edit with untouched-field preservation, corner miter widening and element offsets checked, real `mline_lines` linework before and after DWG and DXF reopen, nine single-edit rejections plus a cross-entity atomic rollback, re-edit after reopen in both formats, deletion, and three-step undo/redo. Known limit: switching to a style with a different element count is rejected until the script also clears the vertices' `segments`; only the rejection is covered by the integration test. | -| Dimension | Layer-only | Track individual dimension subtypes. | +| Dimension | **Complete** (all 9 subtypes) | `EntityType::Dimension` wraps a nine-variant enum, so the adapter uses a hand-written flat model (`dimension_model.rs.inc`, wired in through the manifest's `manual_kinds`) and the coverage catalog lists its properties through the policy's `synthetic` section. Python sees `subtype` (fixed at creation), the shared base fields (`text`, `style_name`, `normal`, `text_middle_point`, `attachment_point`, `text_rotation`, `horizontal_direction`, `flip_arrow1/2`, `text_user_positioned`) and only the fields of that subtype; a field from another subtype is rejected, not ignored. `actual_measurement` and the base definition point are host-derived (`normalize_scripted_dimension`, the same recompute grip edits use). Unmapped but preserved: `user_text`, `insertion_point`, `block_name`, version and DWG flags, style overrides in XDATA. Per-subtype status, all verified by `staged_python_dimension_lifecycle_over_real_ipc` (C/R/E/D/U/I/W/V/P; measurement and canvas wires checked for each, DWG and DXF reopen recorded per subtype): Linear (10), Aligned (5), Radius (5), Diameter (10), Angular3Pt (90°), Angular2Ln (90°), Ordinate (5), Arc (5·π/2), LargeRadial (6) — every one creates, reads, derives the right measurement, renders wires, and keeps its measurement through DWG and DXF. Edits (Linear second point/text/attachment, Radius definition point) update the measurement, nine rejection cases plus a cross-entity atomic rollback and three create-time rejections leave the document untouched, and post-reopen re-edit passes in both formats. Not covered: text formatting overrides, arrowheads and other DIMSTYLE overrides stored in XDATA, the anonymous `*D` block. The subtype-specific field meanings for the angular subtypes follow the DXF group-code layout rather than the acadrust convenience constructors. | | MultiLeader | Layer-only | Requires nested context and style fixture. | | Table | Layer-only | Requires table style and cell fixture. | | PolygonMesh | Layer-only | Requires consistent grid/count fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 3dad4fbe7..76adad3b3 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 24 geometry converters. Other writable properties +canvas kinds outside its 25 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 24-kind generated schema it contains only handle, kind, and layer; +outside the 25-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index edd6e405b..6bdb400c6 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 24 mapped kinds. +the 25 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index 9867780a2..22f77db3f 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -25,6 +25,10 @@ use serde::Deserialize; #[derive(Debug, Deserialize)] pub struct Manifest { + /// Kinds whose payload is a nested enum: converted by hand-written + /// `_to_dict`/`_from_dict`/`_apply` functions. + #[serde(default)] + pub manual_kinds: Vec, /// Entity kinds exposed to `ocs.add`/`ocs.update`/`entity_to_dict`. The /// central, easy-to-widen filter for Phase 2 (see the plan doc). pub type_filter: Vec, @@ -164,6 +168,9 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec { let mut queue: Vec = Vec::new(); for kind in &manifest.type_filter { + if manifest.manual_kinds.contains(kind) { + continue; + } let Some(info) = get(registry, kind) else { panic!("entity_manifest.json: unknown entity kind {kind}"); }; @@ -683,6 +690,13 @@ fn resolve_entity_fields( fn gen_entity_to_dict(manifest: &Manifest, registry: &TypeRegistry) -> String { let mut arms = String::new(); for kind in &manifest.type_filter { + if manifest.manual_kinds.contains(kind) { + arms.push_str(&format!( + " acadrust::EntityType::{kind}(value) => {}_to_dict(vm, value),\n", + snake_case(kind) + )); + continue; + } let info = get(registry, kind).unwrap(); let var = "value"; let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add); @@ -734,6 +748,13 @@ fn gen_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { if manifest.overrides.get(kind).is_some_and(|override_def| override_def.update_only) { continue; } + if manifest.manual_kinds.contains(kind) { + arms.push_str(&format!( + " \"{kind}\" => acadrust::EntityType::{kind}({}_from_dict(dict, vm)?),\n", + snake_case(kind) + )); + continue; + } let info = get(registry, kind).unwrap(); let var = "value"; let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Add); @@ -792,6 +813,13 @@ pub(crate) fn dict_to_entity(dict: &rustpython_vm::builtins::PyDictRef, vm: &Vir fn gen_apply_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { let mut arms = String::new(); for kind in &manifest.type_filter { + if manifest.manual_kinds.contains(kind) { + arms.push_str(&format!( + " acadrust::EntityType::{kind}(existing_value) => acadrust::EntityType::{kind}({}_apply(existing_value, dict, vm)?),\n", + snake_case(kind) + )); + continue; + } let info = get(registry, kind).unwrap(); let var = "value"; let fields = resolve_entity_fields(manifest, registry, kind, info, var, Mode::Update); diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 93da7a608..b6e9450f3 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -23,8 +23,10 @@ "AttributeEntity", "Hatch", "Leader", - "MLine" + "MLine", + "Dimension" ], + "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], "overrides": { "Point": { diff --git a/plugins/opencad-python/src/dimension_model.rs.inc b/plugins/opencad-python/src/dimension_model.rs.inc new file mode 100644 index 000000000..bc3f29923 --- /dev/null +++ b/plugins/opencad-python/src/dimension_model.rs.inc @@ -0,0 +1,327 @@ +// Hand-written Dimension model, `include!`d next to the generated CRUD code. +// +// `EntityType::Dimension` wraps a nine-variant enum, so the generator cannot +// map it. Python sees one flat dict: `subtype` (fixed at creation), the +// shared base fields, and the fields of that subtype only. Fields that belong +// to another subtype are rejected rather than ignored. `actual_measurement` +// and the base `definition_point` are host-derived, never scripted. + +enum DimField<'a> { + V(&'a mut Vector3), + F(&'a mut f64), + B(&'a mut bool), +} + +const DIM_SUBTYPES: &[&str] = &[ + "Aligned", "Linear", "Radius", "Diameter", "Angular2Ln", "Angular3Pt", "Ordinate", "Arc", + "LargeRadial", +]; +const DIM_BASE_KEYS: &[&str] = &[ + "text", "style_name", "normal", "text_middle_point", "attachment_point", "text_rotation", + "horizontal_direction", "flip_arrow1", "flip_arrow2", "text_user_positioned", +]; +const DIM_READ_ONLY_KEYS: &[&str] = &["subtype", "actual_measurement"]; + +fn dimension_subtype_name(dim: &acadrust::entities::Dimension) -> &'static str { + use acadrust::entities::Dimension as D; + match dim { + D::Aligned(_) => "Aligned", + D::Linear(_) => "Linear", + D::Radius(_) => "Radius", + D::Diameter(_) => "Diameter", + D::Angular2Ln(_) => "Angular2Ln", + D::Angular3Pt(_) => "Angular3Pt", + D::Ordinate(_) => "Ordinate", + D::Arc(_) => "Arc", + D::LargeRadial(_) => "LargeRadial", + } +} + +/// Subtype-specific fields in stable order. +fn dimension_fields(dim: &mut acadrust::entities::Dimension) -> Vec<(&'static str, DimField<'_>)> { + use acadrust::entities::Dimension as D; + use DimField::{B, F, V}; + match dim { + D::Aligned(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("ext_line_rotation", F(&mut d.ext_line_rotation)), + ], + D::Linear(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("rotation", F(&mut d.rotation)), + ("ext_line_rotation", F(&mut d.ext_line_rotation)), + ], + D::Radius(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ("leader_length", F(&mut d.leader_length)), + ], + D::Diameter(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ("leader_length", F(&mut d.leader_length)), + ], + D::Angular2Ln(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("dimension_arc", V(&mut d.dimension_arc)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ], + D::Angular3Pt(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_point", V(&mut d.first_point)), + ("second_point", V(&mut d.second_point)), + ("angle_vertex", V(&mut d.angle_vertex)), + ], + D::Ordinate(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("feature_location", V(&mut d.feature_location)), + ("leader_endpoint", V(&mut d.leader_endpoint)), + ("is_ordinate_type_x", B(&mut d.is_ordinate_type_x)), + ], + D::Arc(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("first_extension_point", V(&mut d.first_extension_point)), + ("second_extension_point", V(&mut d.second_extension_point)), + ("center_point", V(&mut d.center_point)), + ("is_partial", B(&mut d.is_partial)), + ("arc_start_parameter", F(&mut d.arc_start_parameter)), + ("arc_end_parameter", F(&mut d.arc_end_parameter)), + ("has_leader", B(&mut d.has_leader)), + ("first_leader_point", V(&mut d.first_leader_point)), + ("second_leader_point", V(&mut d.second_leader_point)), + ], + D::LargeRadial(d) => vec![ + ("definition_point", V(&mut d.definition_point)), + ("chord_point", V(&mut d.chord_point)), + ("jog_angle", F(&mut d.jog_angle)), + ("override_center", V(&mut d.override_center)), + ("jog_point", V(&mut d.jog_point)), + ], + } +} + +/// Fields `ocs.add()` must receive (the geometry that defines the dimension). +fn dimension_required(subtype: &str) -> &'static [&'static str] { + match subtype { + "Aligned" | "Linear" => &["definition_point", "first_point", "second_point"], + "Radius" | "Diameter" => &["definition_point", "angle_vertex"], + "Angular2Ln" => &["definition_point", "dimension_arc", "first_point", "second_point", "angle_vertex"], + "Angular3Pt" => &["definition_point", "first_point", "second_point", "angle_vertex"], + "Ordinate" => &["feature_location", "leader_endpoint"], + "Arc" => &["definition_point", "first_extension_point", "second_extension_point", "center_point"], + "LargeRadial" => &["definition_point", "chord_point"], + _ => &[], + } +} + +fn dimension_base_mut(dim: &mut acadrust::entities::Dimension) -> &mut acadrust::entities::DimensionBase { + dim.base_mut() +} + +fn dim_attachment_point_name(value: acadrust::entities::AttachmentPointType) -> &'static str { + use acadrust::entities::AttachmentPointType as A; + match value { + A::TopLeft => "TopLeft", + A::TopCenter => "TopCenter", + A::TopRight => "TopRight", + A::MiddleLeft => "MiddleLeft", + A::MiddleCenter => "MiddleCenter", + A::MiddleRight => "MiddleRight", + A::BottomLeft => "BottomLeft", + A::BottomCenter => "BottomCenter", + A::BottomRight => "BottomRight", + } +} + +fn parse_dim_attachment_point( + value: &str, + vm: &VirtualMachine, +) -> PyResult { + use acadrust::entities::AttachmentPointType as A; + Ok(match value { + "TopLeft" => A::TopLeft, + "TopCenter" => A::TopCenter, + "TopRight" => A::TopRight, + "MiddleLeft" => A::MiddleLeft, + "MiddleCenter" => A::MiddleCenter, + "MiddleRight" => A::MiddleRight, + "BottomLeft" => A::BottomLeft, + "BottomCenter" => A::BottomCenter, + "BottomRight" => A::BottomRight, + other => { + return Err(vm.new_value_error(format!("ocs: unsupported AttachmentPointType value: {other}"))) + } + }) +} + +fn dimension_to_dict( + vm: &VirtualMachine, + dimension: &acadrust::entities::Dimension, +) -> PyResult { + let mut dim = dimension.clone(); + let dict = vm.ctx.new_dict(); + let base = dimension.base(); + dict.set_item("kind", vm.new_pyobj("Dimension"), vm)?; + dict.set_item("handle", vm.new_pyobj(base.common.handle.value()), vm)?; + dict.set_item("layer", vm.new_pyobj(base.common.layer.clone()), vm)?; + dict.set_item("owner_handle", vm.new_pyobj(base.common.owner_handle.value()), vm)?; + dict.set_item("subtype", vm.new_pyobj(dimension_subtype_name(dimension)), vm)?; + dict.set_item("actual_measurement", vm.new_pyobj(base.actual_measurement), vm)?; + dict.set_item("text", vm.new_pyobj(base.text.clone()), vm)?; + dict.set_item("style_name", vm.new_pyobj(base.style_name.clone()), vm)?; + dict.set_item("normal", vector3_to_py_dict(vm, &base.normal)?, vm)?; + dict.set_item("text_middle_point", vector3_to_py_dict(vm, &base.text_middle_point)?, vm)?; + dict.set_item("attachment_point", vm.new_pyobj(dim_attachment_point_name(base.attachment_point)), vm)?; + dict.set_item("text_rotation", vm.new_pyobj(base.text_rotation), vm)?; + dict.set_item("horizontal_direction", vm.new_pyobj(base.horizontal_direction), vm)?; + dict.set_item("flip_arrow1", vm.new_pyobj(base.flip_arrow1), vm)?; + dict.set_item("flip_arrow2", vm.new_pyobj(base.flip_arrow2), vm)?; + dict.set_item("text_user_positioned", vm.new_pyobj(base.text_user_positioned), vm)?; + for (name, field) in dimension_fields(&mut dim) { + let value: PyObjectRef = match field { + DimField::V(v) => vector3_to_py_dict(vm, v)?, + DimField::F(v) => vm.new_pyobj(*v), + DimField::B(v) => vm.new_pyobj(*v), + }; + dict.set_item(name, value, vm)?; + } + Ok(dict.into()) +} + +/// Merge `dict` onto `dim`. With `creating`, geometry the subtype needs must +/// be present so a script cannot silently default a point to the origin. +fn dimension_merge( + dim: &mut acadrust::entities::Dimension, + dict: &rustpython_vm::builtins::PyDictRef, + creating: bool, + vm: &VirtualMachine, +) -> PyResult<()> { + let subtype = dimension_subtype_name(dim); + let applicable: Vec<&'static str> = + dimension_fields(&mut dim.clone()).into_iter().map(|(name, _)| name).collect(); + for key in dict.keys_vec() { + let key = key.try_into_value::(vm)?; + let key = key.as_str(); + if matches!(key, "kind" | "handle" | "layer" | "owner_handle") || DIM_BASE_KEYS.contains(&key) { + continue; + } + if key == "subtype" { + let value = dict.get_item("subtype", vm)?.try_into_value::(vm)?; + if value != subtype { + return Err(vm.new_value_error("Dimension.subtype is read-only".to_owned())); + } + continue; + } + if key == "actual_measurement" { + return Err(vm.new_value_error("Dimension.actual_measurement is read-only".to_owned())); + } + if !applicable.contains(&key) { + let known = [ + "definition_point", "first_point", "second_point", "angle_vertex", "dimension_arc", + "feature_location", "leader_endpoint", "first_extension_point", "second_extension_point", + "center_point", "first_leader_point", "second_leader_point", "chord_point", + "override_center", "jog_point", "rotation", "ext_line_rotation", "leader_length", + "arc_start_parameter", "arc_end_parameter", "jog_angle", "is_ordinate_type_x", + "is_partial", "has_leader", + ]; + return Err(vm.new_value_error(if known.contains(&key) { + format!("Dimension.{key} does not apply to the {subtype} subtype") + } else { + format!("ocs: Dimension property {key:?} is outside the editable schema") + })); + } + } + if creating { + for name in dimension_required(subtype) { + if dict.get_item_opt(*name, vm)?.is_none() { + return Err(vm.new_value_error(format!("ocs.add: Dimension {subtype} requires {name}"))); + } + } + } + for (name, field) in dimension_fields(dim) { + let Some(value) = dict.get_item_opt(name, vm)? else { continue }; + match field { + DimField::V(target) => *target = py_to_vector3_dict(value, vm)?, + DimField::F(target) => *target = py_number_to_f64(value, vm)?, + DimField::B(target) => *target = value.try_into_value::(vm)?, + } + } + let base = dimension_base_mut(dim); + if let Some(v) = dict.get_item_opt("text", vm)? { base.text = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("style_name", vm)? { base.style_name = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("normal", vm)? { base.normal = py_to_vector3_dict(v, vm)?; } + if let Some(v) = dict.get_item_opt("text_middle_point", vm)? { base.text_middle_point = py_to_vector3_dict(v, vm)?; } + if let Some(v) = dict.get_item_opt("attachment_point", vm)? { + base.attachment_point = parse_dim_attachment_point(&v.try_into_value::(vm)?, vm)?; + } + if let Some(v) = dict.get_item_opt("text_rotation", vm)? { base.text_rotation = py_number_to_f64(v, vm)?; } + if let Some(v) = dict.get_item_opt("horizontal_direction", vm)? { base.horizontal_direction = py_number_to_f64(v, vm)?; } + if let Some(v) = dict.get_item_opt("flip_arrow1", vm)? { base.flip_arrow1 = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("flip_arrow2", vm)? { base.flip_arrow2 = v.try_into_value::(vm)?; } + if let Some(v) = dict.get_item_opt("text_user_positioned", vm)? { base.text_user_positioned = v.try_into_value::(vm)?; } + Ok(()) +} + +fn dimension_from_dict( + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, +) -> PyResult { + use acadrust::entities::*; + let subtype = match dict.get_item_opt("subtype", vm)? { + Some(v) => v.try_into_value::(vm)?, + None => { + return Err(vm.new_value_error(format!( + "ocs.add: Dimension requires subtype, one of {DIM_SUBTYPES:?}" + ))) + } + }; + let mut dim = match subtype.as_str() { + "Aligned" => Dimension::Aligned(DimensionAligned::default()), + "Linear" => Dimension::Linear(DimensionLinear::default()), + "Radius" => Dimension::Radius(DimensionRadius::default()), + "Diameter" => Dimension::Diameter(DimensionDiameter::default()), + "Angular2Ln" => Dimension::Angular2Ln(DimensionAngular2Ln::default()), + "Angular3Pt" => Dimension::Angular3Pt(DimensionAngular3Pt::default()), + "Ordinate" => Dimension::Ordinate(DimensionOrdinate::default()), + "Arc" => Dimension::Arc(DimensionArc::default()), + "LargeRadial" => Dimension::LargeRadial(DimensionLargeRadial::default()), + other => { + return Err(vm.new_value_error(format!( + "ocs.add: unsupported Dimension subtype {other:?}, expected one of {DIM_SUBTYPES:?}" + ))) + } + }; + dimension_merge(&mut dim, dict, true, vm)?; + let base = dimension_base_mut(&mut dim); + base.common.handle = acadrust::Handle::new(get_opt_u64(dict, "handle", vm)?); + base.common.layer = get_opt_string(dict, "layer", "0", vm)?; + base.common.owner_handle = acadrust::Handle::new(get_opt_u64(dict, "owner_handle", vm)?); + Ok(dim) +} + +fn dimension_apply( + existing: &acadrust::entities::Dimension, + dict: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, +) -> PyResult { + let mut dim = existing.clone(); + dimension_merge(&mut dim, dict, false, vm)?; + let base = dimension_base_mut(&mut dim); + if let Some(v) = dict.get_item_opt("layer", vm)? { + if !vm.is_none(&v) { + base.common.layer = v.try_into_value::(vm)?; + } + } + if let Some(owner) = dict.get_item_opt("owner_handle", vm)? { + if owner.try_into_value::(vm)? != base.common.owner_handle.value() { + return Err(vm.new_value_error("owner_handle is read-only after creation".to_owned())); + } + } + Ok(dim) +} diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index a1e3c1448..290c8da6e 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -3127,10 +3127,15 @@ mod ocs { match dict.get_item_opt(key, vm)? { None => Ok(0.0), Some(v) if vm.is_none(&v) => Ok(0.0), - Some(v) => v.try_into_value::(vm), + Some(v) => py_number_to_f64(v, vm), } } + /// Accept a Python int or float; `try_into_value::` rejects ints. + fn py_number_to_f64(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + Ok(value.try_float(vm)?.to_f64()) + } + fn get_opt_bool( dict: &rustpython_vm::builtins::PyDictRef, key: &str, @@ -3186,6 +3191,7 @@ mod ocs { } include!(concat!(env!("OUT_DIR"), "/entity_crud.rs")); + include!("dimension_model.rs.inc"); // ════════════════════════════════════════════════════════════════════ // Phase 1 — wiring the generic conversion functions above into `ocs`. @@ -4640,6 +4646,23 @@ mod tests { current = None; } } + // Hand-written kinds: keys come from the include file's dict writes + // and per-subtype field table. + let manual = include_str!("dimension_model.rs.inc"); + let dimension_keys = emitted.entry("Dimension".to_owned()).or_default(); + for line in manual.lines() { + let line = line.trim(); + let key = line + .strip_prefix("dict.set_item(\"") + .or_else(|| { + (line.contains("\", V(") || line.contains("\", F(") || line.contains("\", B(")) + .then(|| line.strip_prefix("(\"")) + .flatten() + }); + if let Some(key) = key { + dimension_keys.insert(key.split('"').next().unwrap().to_owned()); + } + } for entry in &catalog.entity_kinds { let expected: BTreeSet<_> = entry .properties @@ -4663,7 +4686,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 24); + assert_eq!(emitted.len(), 25); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 67c7fedce..0c96af428 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -155,6 +155,9 @@ impl<'a> HostSession<'a> { old: Option<&EntityType>, entity: &mut EntityType, ) -> Result<(), String> { + if let EntityType::Dimension(dimension) = entity { + crate::entities::dimension::normalize_scripted_dimension(dimension); + } if let EntityType::MLine(new) = entity { let old = match old { Some(EntityType::MLine(old)) => Some(old), @@ -2572,6 +2575,209 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } + #[test] + fn staged_python_dimension_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + + // (subtype, python fields, expected measurement) + let fixtures: [(&str, &str, f64); 9] = [ + ("Linear", "definition_point=P(10,5), first_point=P(0,0), second_point=P(10,0)", 10.0), + ("Aligned", "definition_point=P(1,5.5), first_point=P(0,0), second_point=P(3,4)", 5.0), + ("Radius", "definition_point=P(5,0), angle_vertex=P(0,0)", 5.0), + ("Diameter", "definition_point=P(10,0), angle_vertex=P(0,0)", 10.0), + ("Angular3Pt", "definition_point=P(3,3), first_point=P(5,0), second_point=P(0,5), angle_vertex=P(0,0)", 90.0), + ("Angular2Ln", "definition_point=P(0,5), dimension_arc=P(3,3), first_point=P(0,0), second_point=P(5,0), angle_vertex=P(0,0)", 90.0), + ("Ordinate", "feature_location=P(5,3), leader_endpoint=P(5,8), is_ordinate_type_x=True", 5.0), + ("Arc", "definition_point=P(3.5,3.5), first_extension_point=P(5,0), second_extension_point=P(0,5), center_point=P(0,0), arc_start_parameter=0.0, arc_end_parameter=1.5707963267948966", 5.0 * std::f64::consts::FRAC_PI_2), + ("LargeRadial", "definition_point=P(10,0), chord_point=P(4,0)", 6.0), + ]; + let mut script = String::from("def P(x, y): return {'x': x, 'y': y, 'z': 0.0}\ndoc = ocs.active_document\n"); + for (subtype, fields, _) in &fixtures { + script.push_str(&format!("doc.create_entity('Dimension', subtype='{subtype}', {fields})\n")); + } + let path = std::env::temp_dir().join(format!("ocs_dimension_create_{}.py", std::process::id())); + std::fs::write(&path, script).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", path.display())); + let _ = std::fs::remove_file(&path); + let subtype_of = |dimension: &acadrust::entities::Dimension| -> &'static str { + use acadrust::entities::Dimension as D; + match dimension { + D::Aligned(_) => "Aligned", D::Linear(_) => "Linear", D::Radius(_) => "Radius", + D::Diameter(_) => "Diameter", D::Angular2Ln(_) => "Angular2Ln", + D::Angular3Pt(_) => "Angular3Pt", D::Ordinate(_) => "Ordinate", + D::Arc(_) => "Arc", D::LargeRadial(_) => "LargeRadial", + } + }; + let handles_by_subtype = |document: &CadDocument| { + let mut map = std::collections::BTreeMap::new(); + for entity in document.entities() { + if let EntityType::Dimension(dimension) = entity { + map.insert(subtype_of(dimension), dimension.base().common.handle); + } + } + map + }; + let handles = handles_by_subtype(host.document()); + assert_eq!(handles.len(), 9, "created: {handles:?}; {}", last_output(&host)); + let measured = |document: &CadDocument, handle: Handle| -> f64 { + let Some(EntityType::Dimension(dimension)) = document.get_entity(handle) else { + panic!("Dimension missing"); + }; + dimension.base().actual_measurement + }; + for (subtype, _, expected) in &fixtures { + let value = measured(host.document(), handles[subtype]); + assert!((value - expected).abs() < 1e-6, "{subtype}: measured {value}, expected {expected}"); + let Some(EntityType::Dimension(dimension)) = host.document().get_entity(handles[subtype]) else { unreachable!() }; + assert_eq!(dimension.measurement(), value, "{subtype}: stored measurement is derived"); + let wires = host.app.tabs[0].scene.wire_models_for(&[handles[subtype]]); + assert!(!wires.is_empty(), "{subtype}: dimension produced no canvas geometry"); + } + let linear = handles["Linear"]; + let created_linear = host.document().get_entity(linear).unwrap().clone(); + + // Edit: move a defining point (the measurement follows), change text + // and style-independent placement; untouched fields survive. + let script = std::env::temp_dir().join(format!("ocs_dimension_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "linear = doc.entities[{}]\n", + "radius = doc.entities[{}]\n", + "with doc.transaction('Edit dimensions'):\n", + " linear.second_point = (20.0, 0.0, 0.0)\n", + " linear.text = '<> mm'\n", + " linear.attachment_point = 'TopCenter'\n", + " radius.definition_point = (8.0, 0.0, 0.0)\n", + "doc.selection = [linear]\n" + ), linear.value(), handles["Radius"].value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected_linear = host.document().get_entity(linear).unwrap().clone(); + let EntityType::Dimension(edited) = &expected_linear else { unreachable!() }; + let EntityType::Dimension(before) = &created_linear else { unreachable!() }; + assert!((edited.base().actual_measurement - 20.0).abs() < 1e-9, "edit failed: {}", last_output(&host)); + assert!((measured(host.document(), handles["Radius"]) - 8.0).abs() < 1e-9); + assert_eq!(edited.base().text, "<> mm"); + assert_eq!(edited.base().attachment_point, acadrust::entities::AttachmentPointType::TopCenter); + assert_eq!(edited.base().common, before.base().common); + assert_eq!(edited.base().style_name, before.base().style_name); + assert_eq!(edited.base().normal, before.base().normal); + assert_eq!(host.selection(), vec![linear]); + let expected_radius = host.document().get_entity(handles["Radius"]).unwrap().clone(); + assert!(!host.app.tabs[0].scene.wire_models_for(&[linear]).is_empty()); + + let reject_cases: [(&str, &str, &str); 9] = [ + ("Linear", "'leader_length':2.0", "does not apply to the Linear subtype"), + ("Linear", "'subtype':'Radius'", "read-only"), + ("Linear", "'actual_measurement':5.0", "read-only"), + ("Linear", "'style_name':'Missing'", "does not exist"), + ("Linear", "'first_point':{'x':20.0,'y':0.0,'z':0.0}", "must not coincide"), + ("Linear", "'first_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("Linear", "'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("Radius", "'angle_vertex':{'x':8.0,'y':0.0,'z':0.0}", "must not coincide"), + ("Linear", "'attachment_point':'Nowhere'", "unsupported AttachmentPointType"), + ]; + for (subtype, patch, message) in reject_cases { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject dimension', [{{'handle':{}, {patch}}}])", + handles[subtype].value())); + assert_eq!(host.document().get_entity(linear), Some(&expected_linear), "{patch}"); + assert_eq!(host.document().get_entity(handles["Radius"]), Some(&expected_radius), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'text':'changed'}},", + "{{'handle':{},'style_name':'Missing'}}])" + ), linear.value(), handles["Radius"].value())); + assert_eq!(host.document().get_entity(linear), Some(&expected_linear)); + for (patch, message) in [ + ("ocs.active_document.create_entity('Dimension', definition_point=P(0,0))", "requires subtype"), + ("ocs.active_document.create_entity('Dimension', subtype='Cone')", "unsupported Dimension subtype"), + ("ocs.active_document.create_entity('Dimension', subtype='Linear', first_point=P(0,0), second_point=P(1,0))", "requires definition_point"), + ] { + dispatch(&mut host, &format!("PY_EVAL (lambda P: {patch})(lambda x, y: {{'x': x, 'y': y, 'z': 0.0}})")); + assert_eq!(handles_by_subtype(host.document()).len(), 9, "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + + // DWG and DXF: every subtype is recorded separately. + let mut failures = Vec::new(); + let dwg = crate::io::load_bytes("dimension.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("dimension.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + let reopened = handles_by_subtype(document); + for (subtype, _, expected) in &fixtures { + let expected = match *subtype { "Linear" => 20.0, "Radius" => 8.0, _ => *expected }; + match reopened.get(subtype) { + None => failures.push(format!("{format} {subtype}: missing")), + Some(handle) => { + let value = measured(document, *handle); + if (value - expected).abs() > 1e-6 { + failures.push(format!("{format} {subtype}: measurement {value} != {expected}")); + } + } + } + } + let Some(EntityType::Dimension(dimension)) = document.get_entity(linear) else { + failures.push(format!("{format} Linear handle lost")); + continue; + }; + if dimension.base().text != "<> mm" { failures.push(format!("{format} Linear text {:?}", dimension.base().text)); } + } + assert!(failures.is_empty(), "persistence gaps: {failures:#?}"); + for (name, mut document) in [("dimension-reedit.dwg", dwg), ("dimension-reedit.dxf", dxf)] { + let before = document.get_entity(linear).unwrap().clone(); + let mut after = before.clone(); + let EntityType::Dimension(dimension) = &mut after else { unreachable!() }; + if let acadrust::entities::Dimension::Linear(value) = dimension { value.second_point.x = 30.0; } + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + let EntityType::Dimension(dimension) = &mut after else { unreachable!() }; + crate::entities::dimension::normalize_scripted_dimension(dimension); + *document.get_entity_mut(linear).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + let reopened = crate::io::load_bytes(name, bytes).unwrap(); + assert!((measured(&reopened, linear) - 30.0).abs() < 1e-6, "{name}"); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", linear.value())); + assert!(host.document().get_entity(linear).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(linear), Some(&expected_linear)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(linear), Some(&created_linear)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(linear).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(linear).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 6cf37e0a5..638a21179 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -25,6 +25,14 @@ pub(crate) fn set_dimension_text_override(base: &mut DimensionBase, text: Option base.user_text = text; } +/// Recompute the fields a script cannot set: the base definition point and +/// the stored measurement, exactly as grip edits do. +pub(crate) fn normalize_scripted_dimension(dim: &mut Dimension) { + let definition_point = dimension_definition_point(dim); + dim.base_mut().definition_point = definition_point; + dim.base_mut().actual_measurement = dim.measurement(); +} + fn dimension_definition_point(dim: &Dimension) -> acadrust::types::Vector3 { match dim { Dimension::Aligned(d) => d.definition_point, From 22f9c20fe88ba915be95e8b5aaf3624e60fa9e3c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 14:18:12 +0200 Subject: [PATCH 20/76] Support Color in the Python adapter generator; map Leader.override_color Color converts as {'kind': ByLayer|None|ByBlock|Index|Rgb, 'value': ...}. Leader.override_color is now writable; neither DWG nor DXF stores it, which is recorded as a fourth persistence blocker with a canary assertion. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 1 + crates/ocs_plugin_api/src/entity_coverage.rs | 1 + docs/plugin-host-model-coverage-ledger.md | 2 +- plugins/opencad-python/build/generate.rs | 5 +- plugins/opencad-python/entity_manifest.json | 6 +- plugins/opencad-python/src/ocs_module.rs | 57 +++++++++++++++++++ src/app/plugin_host.rs | 9 ++- 8 files changed, 73 insertions(+), 9 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 77623fd0e..9377cb31e 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -778,6 +778,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | "text_height" | "text_width" | "vertices" + | "override_color" | "annotation_handle" | "normal" | "horizontal_direction" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index b60db1e29..62990ba5f 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -281,6 +281,7 @@ "text_height", "text_width", "vertices", + "override_color", "annotation_handle", "normal", "horizontal_direction", diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 51c268545..8376ccc98 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1684,6 +1684,7 @@ mod tests { "Leader.text_width".to_owned(), "Leader.vertices".to_owned(), "Leader.annotation_handle".to_owned(), + "Leader.override_color".to_owned(), "Leader.normal".to_owned(), "Leader.horizontal_direction".to_owned(), "Leader.block_offset".to_owned(), diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 0118f6573..73bbd1d91 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -28,7 +28,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | AttributeDefinition | **Integration-tested / DXF blocker pending revalidation** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, rendered text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open:** acadrust `568a12c` dropped optional ATTDEF fields including width factor while reading DXF. Rerun the regression against the currently merged `7ea4247` revision and update this row with the result. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | | AttributeEntity | **Complete** | OCS commit `cf2a9235` and adapter commit `9c6b5c3` make nested ATTRIB records addressable by handle while retaining canonical storage in `Insert.attributes`. Host validation binds tag to a definition in the Insert's block, validates placement/text geometry and style references, keeps owner and definition handles read-only, and preserves embedded MTEXT. `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a real block, ATTDEF and Insert: high-level create/read/transaction edit/delete, parent selection, rendered text, atomic invalid tag/height/owner rejection, edited and deleted DWG/DXF reopen, and three-step undo/redo. `nested_insert_attribute_crud_uses_parent_storage_and_undo` additionally proves the child never becomes an orphan flat entity in the host document. | | Hatch | **Complete** | OCS commit `98b56ef6` and adapter commit `a9b5de8` expose solid and patterned Hatches with elevation/normal, complete pattern definitions, pattern controls, style, association state, boundary paths and seed points. Nested tagged boundary edges use the reusable `{kind, value}` adapter form. Host validation requires closed finite loops, validates line/arc/ellipse/polyline/spline payloads and stored pattern lines, resolves associative boundary handles, and preserves gradient and MPOLYGON payloads as explicitly unsupported. `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: Python solid creation, patterned boundary edit, live render-model pattern/boundary checks, selection, atomic invalid scale/association/open-loop/unmapped-property rejection, edited and deleted DWG/DXF reopen, post-reopen edits, and three-step undo/redo. | -| Leader | **Integration-tested / blocked at W by acadrust** | Fields mapped writable: `vertices` (≥2 finite points, not all coincident), `annotation_handle`, `creation_type`, `path_type`, `dimension_style`, `arrow_enabled`, `hookline_enabled`, `hookline_direction`, `text_height`, `text_width`, `normal`, `horizontal_direction`, `block_offset`, `annotation_offset`. Read-only: `origin`, `dimension_gap`, `arrowhead_type`, `arrow_size`, `byblock_color`. Unmapped but preserved on edit: `override_color` (multi-payload `Color` enum the generator cannot express) and the `dwg_unknown_*` fields. Host reference binding validates that `annotation_handle` exists, is not the leader itself, matches `creation_type` (Text/MText, Tolerance, Insert; `NoAnnotation` forbids a handle) and that `dimension_style` exists; nothing is silently defaulted. `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P against acadrust `7ea4247`: linked Text annotation, path/height/arrow/offset transaction edit with untouched-field preservation, GUI pick grouping (scripted selection stays exact), seven single-edit rejections plus a cross-entity atomic rollback, four-step undo/redo, real render linework, DWG and DXF reopen and re-edit. Erasing a leader also erases its annotation (scene leader grouping); undo restores both. **W blockers (engine, recorded as canary assertions in the test):** (1) the DXF writer never emits group 340, so a DXF save unlinks the annotation; (2) DXF reads drop `annotation_offset` (group 213 is written but not reassembled); (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (acadrust writes them only for R13–R2007 / R13–R14). Not `Complete` until the engine round-trips (1) and (2); (3) needs a decision to document or make read-only. | +| Leader | **Integration-tested / blocked at W by acadrust** | Fields mapped writable: `vertices` (≥2 finite points, not all coincident), `annotation_handle`, `creation_type`, `path_type`, `dimension_style`, `arrow_enabled`, `hookline_enabled`, `hookline_direction`, `text_height`, `text_width`, `normal`, `horizontal_direction`, `block_offset`, `annotation_offset`. Read-only: `origin`, `dimension_gap`, `arrowhead_type`, `arrow_size`, `byblock_color`. `override_color` is writable through the new generator `Color` form (`{'kind': 'Rgb', 'value': {'r','g','b'}}`, `Index`, `ByLayer`, `ByBlock`, `None`). Unmapped but preserved on edit: the `dwg_unknown_*` fields. Host reference binding validates that `annotation_handle` exists, is not the leader itself, matches `creation_type` (Text/MText, Tolerance, Insert; `NoAnnotation` forbids a handle) and that `dimension_style` exists; nothing is silently defaulted. `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P against acadrust `7ea4247`: linked Text annotation, path/height/arrow/offset transaction edit with untouched-field preservation, GUI pick grouping (scripted selection stays exact), seven single-edit rejections plus a cross-entity atomic rollback, four-step undo/redo, real render linework, DWG and DXF reopen and re-edit. Erasing a leader also erases its annotation (scene leader grouping); undo restores both. **W blockers (engine, recorded as canary assertions in the test):** (1) the DXF writer never emits group 340, so a DXF save unlinks the annotation; (2) DXF reads drop `annotation_offset` (group 213 is written but not reassembled); (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (acadrust writes them only for R13–R2007 / R13–R14); (4) neither writer stores `override_color`, so it reopens as `ByLayer` in DWG and DXF. Not `Complete` until the engine round-trips (1) and (2); (3) needs a decision to document or make read-only. | | MLine | **Complete** | Writable: `vertices` (raw vertex dicts; a script may pass only `position`), `flags` (integer, unknown bits rejected), `justification`, `normal`, `scale_factor`, `style_name`. Read-only: `start_point`, `style_handle`, `style_element_count`. Host validation requires ≥2 finite non-coincident vertices, ≥3 for a closed multiline, a finite nonzero scale and normal, an existing non-empty `MLineStyle`, and per-vertex segments that are either empty or match the style element count. Vertex `direction`/`miter`, segment layout and per-element miter offsets (justification and scale included) are host-derived by `normalize_scripted_mline` on every scripted create or edit, reusing the drawing command's `rebuild_mline_geometry`/`sync_mline_element_parameters`; dash and fill cuts survive an edit unless a vertex moves. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: create against the Standard style, a three-vertex/scale/justification transaction edit with untouched-field preservation, corner miter widening and element offsets checked, real `mline_lines` linework before and after DWG and DXF reopen, nine single-edit rejections plus a cross-entity atomic rollback, re-edit after reopen in both formats, deletion, and three-step undo/redo. Known limit: switching to a style with a different element count is rejected until the script also clears the vertices' `segments`; only the rejection is covered by the integration test. | | Dimension | **Complete** (all 9 subtypes) | `EntityType::Dimension` wraps a nine-variant enum, so the adapter uses a hand-written flat model (`dimension_model.rs.inc`, wired in through the manifest's `manual_kinds`) and the coverage catalog lists its properties through the policy's `synthetic` section. Python sees `subtype` (fixed at creation), the shared base fields (`text`, `style_name`, `normal`, `text_middle_point`, `attachment_point`, `text_rotation`, `horizontal_direction`, `flip_arrow1/2`, `text_user_positioned`) and only the fields of that subtype; a field from another subtype is rejected, not ignored. `actual_measurement` and the base definition point are host-derived (`normalize_scripted_dimension`, the same recompute grip edits use). Unmapped but preserved: `user_text`, `insertion_point`, `block_name`, version and DWG flags, style overrides in XDATA. Per-subtype status, all verified by `staged_python_dimension_lifecycle_over_real_ipc` (C/R/E/D/U/I/W/V/P; measurement and canvas wires checked for each, DWG and DXF reopen recorded per subtype): Linear (10), Aligned (5), Radius (5), Diameter (10), Angular3Pt (90°), Angular2Ln (90°), Ordinate (5), Arc (5·π/2), LargeRadial (6) — every one creates, reads, derives the right measurement, renders wires, and keeps its measurement through DWG and DXF. Edits (Linear second point/text/attachment, Radius definition point) update the measurement, nine rejection cases plus a cross-entity atomic rollback and three create-time rejections leave the document untouched, and post-reopen re-edit passes in both formats. Not covered: text formatting overrides, arrowheads and other DIMSTYLE overrides stored in XDATA, the anonymous `*D` block. The subtype-specific field meanings for the angular subtypes follow the DXF group-code layout rather than the acadrust convenience constructors. | | MultiLeader | Layer-only | Requires nested context and style fixture. | diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index 22f77db3f..9affcca6e 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -110,7 +110,7 @@ const BUILTIN: &[&str] = &[ ]; /// Types with their own hand-written conversion helpers in `ocs_module.rs` /// (`vector3_to_py`/`py_to_vector3`, etc.) rather than generated ones. -const SPECIAL: &[&str] = &["Vector3", "Vector2", "Handle"]; +const SPECIAL: &[&str] = &["Vector3", "Vector2", "Handle", "Color"]; enum Classify { /// A C-like enum: every variant is a unit variant. Represented in Python @@ -289,6 +289,7 @@ fn default_expr(type_id: &str) -> String { "Vector3" => "acadrust::types::Vector3::ZERO".to_string(), "Vector2" => "acadrust::types::Vector2::ZERO".to_string(), "Handle" => "acadrust::Handle::new(0)".to_string(), + "Color" => "acadrust::types::Color::ByLayer".to_string(), other => format!("default_{}()", snake_case(other)), } } @@ -309,6 +310,7 @@ fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String "Vector3" => format!("vector3_to_py_dict(vm, {item_expr})"), "Vector2" => format!("vector2_to_py(vm, {item_expr})"), "Handle" => format!("Ok(vm.new_pyobj({parenthesized}.value()))"), + "Color" => format!("color_to_py_dict(vm, {item_expr})"), other => { let info = get(registry, other) .unwrap_or_else(|| panic!("type {other} missing from registry")); @@ -333,6 +335,7 @@ fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> Str "Vector3" => format!("py_to_vector3_dict({value_expr}, vm)"), "Vector2" => format!("py_to_vector2({value_expr}, vm)"), "Handle" => format!("{value_expr}.try_into_value::(vm).map(acadrust::Handle::new)"), + "Color" => format!("py_to_color_dict({value_expr}, vm)"), other => { let info = get(registry, other) .unwrap_or_else(|| panic!("type {other} missing from registry")); diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index b6e9450f3..2799c4a14 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -262,11 +262,7 @@ "dwg_unknown_bit3": { "exclude": true }, "dwg_unknown_bit4": { "exclude": true }, "dwg_unknown_bit5": { "exclude": true }, - "dwg_unknown_short1": { "exclude": true }, - "override_color": { - "exclude": true, - "_comment": "Color is a multi-payload tagged enum the generator cannot express yet. It stays snapshot-readable and is preserved untouched by Python edits." - } + "dwg_unknown_short1": { "exclude": true } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 290c8da6e..3ba9dd921 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -3131,6 +3131,63 @@ mod ocs { } } + /// `Color` as `{"kind": "ByLayer" | "None" | "ByBlock"}`, + /// `{"kind": "Index", "value": n}` or + /// `{"kind": "Rgb", "value": {"r": .., "g": .., "b": ..}}`. + fn color_to_py_dict(vm: &VirtualMachine, color: &acadrust::types::Color) -> PyResult { + use acadrust::types::Color; + let dict = vm.ctx.new_dict(); + match color { + Color::ByLayer => dict.set_item("kind", vm.new_pyobj("ByLayer"), vm)?, + Color::None => dict.set_item("kind", vm.new_pyobj("None"), vm)?, + Color::ByBlock => dict.set_item("kind", vm.new_pyobj("ByBlock"), vm)?, + Color::Index(index) => { + dict.set_item("kind", vm.new_pyobj("Index"), vm)?; + dict.set_item("value", vm.new_pyobj(i64::from(*index)), vm)?; + } + Color::Rgb { r, g, b } => { + dict.set_item("kind", vm.new_pyobj("Rgb"), vm)?; + let rgb = vm.ctx.new_dict(); + rgb.set_item("r", vm.new_pyobj(i64::from(*r)), vm)?; + rgb.set_item("g", vm.new_pyobj(i64::from(*g)), vm)?; + rgb.set_item("b", vm.new_pyobj(i64::from(*b)), vm)?; + dict.set_item("value", PyObjectRef::from(rgb), vm)?; + } + } + Ok(dict.into()) + } + + fn py_to_color_dict(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use acadrust::types::Color; + let dict = value.try_into_value::(vm)?; + let kind = dict.get_item("kind", vm)?.try_into_value::(vm)?; + let byte = |v: PyObjectRef, what: &str| -> PyResult { + let n = v.try_into_value::(vm)?; + u8::try_from(n).map_err(|_| vm.new_value_error(format!("Color {what} must be 0..=255"))) + }; + Ok(match kind.as_str() { + "ByLayer" => Color::ByLayer, + "None" => Color::None, + "ByBlock" => Color::ByBlock, + "Index" => { + let index = byte(dict.get_item("value", vm)?, "index")?; + if index == 0 { + return Err(vm.new_value_error("Color index must be 1..=255; use ByBlock for 0".to_owned())); + } + Color::Index(index) + } + "Rgb" => { + let rgb = dict.get_item("value", vm)?.try_into_value::(vm)?; + Color::Rgb { + r: byte(rgb.get_item("r", vm)?, "r")?, + g: byte(rgb.get_item("g", vm)?, "g")?, + b: byte(rgb.get_item("b", vm)?, "b")?, + } + } + other => return Err(vm.new_value_error(format!("ocs: unsupported Color kind: {other}"))), + }) + } + /// Accept a Python int or float; `try_into_value::` rejects ints. fn py_number_to_f64(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { Ok(value.try_float(vm)?.to_f64()) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 0c96af428..f968b3999 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -2265,6 +2265,7 @@ mod tests { " leader.text_height = 4.0\n", " leader.arrow_enabled = False\n", " leader.annotation_offset = (1.0, 2.0, 0.0)\n", + " leader.override_color = {{'kind':'Rgb','value':{{'r':255,'g':0,'b':0}}}}\n", "doc.selection = [leader]\n" ), leader_handle.value())).unwrap(); dispatch(&mut host, &format!("PY_RUN {}", script.display())); @@ -2281,7 +2282,7 @@ mod tests { assert_eq!(edited.hookline_enabled, created_leader.hookline_enabled); assert_eq!(edited.normal, created_leader.normal); assert_eq!(edited.origin, created_leader.origin); - assert_eq!(edited.override_color, created_leader.override_color); + assert_eq!(edited.override_color, acadrust::types::Color::Rgb { r: 255, g: 0, b: 0 }); assert_eq!(host.selection(), vec![leader_handle]); assert_geometry(&expected, host.document()); @@ -2292,7 +2293,7 @@ mod tests { ("'vertices':[{'x':0.0,'y':0.0,'z':0.0}]", "at least 2"), ("'text_height':0.0", "greater than zero"), ("'dimension_style':'Missing'", "dimension style"), - ("'override_color':{}", "outside the editable schema"), + ("'override_color':{'kind':'Index','value':300}", "Color"), ] { dispatch(&mut host, &format!( "PY_EVAL ocs.update_many('Reject leader', [{{'handle':{}, {patch}}}])", @@ -2325,6 +2326,10 @@ mod tests { assert_eq!(leader.annotation_handle, text_handle); } assert!(!leader.arrow_enabled); + // BLOCKER: neither writer stores `override_color`, so it reopens + // as ByLayer in both formats. + assert_eq!(leader.override_color, acadrust::types::Color::ByLayer, + "acadrust now persists Leader.override_color; drop the blocker"); if is_dxf { // BLOCKER: the DXF writer emits group 213 but the reader's // coordinate mapping does not reassemble it. From b3f5388208b54572a1c5720a2dfcc5f7a4104858 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 14:27:43 +0200 Subject: [PATCH 21/76] Complete MultiLeader host model coverage Extends the adapter generator (Color, LineWeight, mixed unit/payload enums, fixed float arrays, bitflags-2 newtypes, keep_types override) and maps MultiLeader with a merge-style context patch, host validation and reference checks. staged_python_multileader_lifecycle_over_real_ipc passes the full gate including DWG and DXF reopen. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 41 +++++ crates/ocs_plugin_api/src/entity_coverage.rs | 138 ++++++++++++++++ docs/.DS_Store | Bin 0 -> 6148 bytes docs/Geometric Constraint Reference.rtf | 41 +++++ docs/plugin-host-model-completion-plan.md | 11 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/build/generate.rs | 102 +++++++++--- plugins/opencad-python/entity_manifest.json | 18 +- plugins/opencad-python/src/ocs_module.rs | 15 +- .../opencad-python/src/patch_helpers.rs.inc | 23 +++ src/app/plugin_host.rs | 154 ++++++++++++++++++ 14 files changed, 523 insertions(+), 31 deletions(-) create mode 100644 docs/.DS_Store create mode 100644 docs/Geometric Constraint Reference.rtf create mode 100644 plugins/opencad-python/src/patch_helpers.rs.inc diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 9377cb31e..985ddedef 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -785,6 +785,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | "block_offset" | "annotation_offset" ) + | ("MultiLeader", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 62990ba5f..5fb91e8a8 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -296,6 +296,41 @@ "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" ] }, "readable": { @@ -322,6 +357,12 @@ ], "AttributeEntity": [ "attdef_handle" + ], + "MultiLeader": [ + "dwg_version", + "property_override_flags", + "text_align_in_ipe", + "text_height" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 8376ccc98..e569f41fe 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -465,6 +465,89 @@ fn validate_dimension( Ok(()) } +/// Field checks shared by MultiLeader creation and mutation. +#[cfg(feature = "host")] +fn validate_multileader( + old: Option<&crate::host::acadrust::entities::MultiLeader>, + new: &crate::host::acadrust::entities::MultiLeader, +) -> Result<(), String> { + use crate::host::acadrust::entities::LeaderContentType; + if old == Some(new) { + return Ok(()); + } + for (name, value, allow_zero) in [ + ("dogleg_length", new.dogleg_length, true), + ("arrowhead_size", new.arrowhead_size, true), + ("text_height", new.text_height, false), + ("scale_factor", new.scale_factor, false), + ] { + if !value.is_finite() || value < 0.0 || (!allow_zero && value == 0.0) { + return Err(format!( + "MultiLeader.{name} must be finite and {}", + if allow_zero { "non-negative" } else { "greater than zero" } + )); + } + } + if !new.block_rotation.is_finite() { + return Err("MultiLeader.block_rotation must be finite".into()); + } + finite_vector("MultiLeader.block_scale", &new.block_scale)?; + let context = &new.context; + if !context.scale_factor.is_finite() || context.scale_factor <= 0.0 { + return Err("MultiLeader.context.scale_factor must be finite and greater than zero".into()); + } + for (name, value) in [ + ("text_height", context.text_height), + ("text_width", context.text_width), + ("text_rotation", context.text_rotation), + ("landing_gap", context.landing_gap), + ("arrowhead_size", context.arrowhead_size), + ] { + if !value.is_finite() { + return Err(format!("MultiLeader.context.{name} must be finite")); + } + } + for (name, point) in [ + ("content_base_point", &context.content_base_point), + ("text_location", &context.text_location), + ("block_content_location", &context.block_content_location), + ("base_point", &context.base_point), + ] { + finite_vector(&format!("MultiLeader.context.{name}"), point)?; + } + if context.transform_matrix.iter().any(|value| !value.is_finite()) { + return Err("MultiLeader.context.transform_matrix must be finite".into()); + } + for (ri, root) in context.leader_roots.iter().enumerate() { + finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].connection_point"), &root.connection_point)?; + finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].direction"), &root.direction)?; + if !root.landing_distance.is_finite() { + return Err(format!("MultiLeader.context.leader_roots[{ri}].landing_distance must be finite")); + } + for (li, line) in root.lines.iter().enumerate() { + if line.points.is_empty() { + return Err(format!("MultiLeader.context.leader_roots[{ri}].lines[{li}] has no points")); + } + for (pi, point) in line.points.iter().enumerate() { + finite_vector(&format!("MultiLeader.context.leader_roots[{ri}].lines[{li}].points[{pi}]"), point)?; + } + } + } + match new.content_type { + LeaderContentType::MText if !context.has_text_contents => { + return Err("MText MultiLeader requires context.has_text_contents".into()); + } + LeaderContentType::Block if !context.has_block_contents => { + return Err("Block MultiLeader requires context.has_block_contents".into()); + } + LeaderContentType::None if context.has_text_contents || context.has_block_contents => { + return Err("MultiLeader without content cannot carry text or block contents".into()); + } + _ => {} + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -559,6 +642,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::Leader(value) => validate_leader(None, value)?, EntityType::MLine(value) => validate_mline(None, value)?, EntityType::Dimension(value) => validate_dimension(None, value)?, + EntityType::MultiLeader(value) => validate_multileader(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -914,6 +998,7 @@ pub fn validate_entity_mutation( } } (EntityType::Dimension(old), EntityType::Dimension(new)) => validate_dimension(Some(old), new)?, + (EntityType::MultiLeader(old), EntityType::MultiLeader(new)) => validate_multileader(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -1177,6 +1262,26 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::MultiLeader(value) = entity { + use crate::host::acadrust::objects::ObjectType; + let handle_exists = |handle: Option, what: &str, ok: &dyn Fn(crate::host::acadrust::Handle) -> bool| { + match handle.filter(|handle| !handle.is_null()) { + Some(handle) if !ok(handle) => Err(format!("MultiLeader {what} handle {handle:?} does not exist")), + _ => Ok(()), + } + }; + let is_block = |h| document.block_records.iter().any(|record| record.handle == h); + let is_text_style = |h| document.text_styles.iter().any(|style| style.handle == h); + let is_linetype = |h| document.line_types.iter().any(|ltype| ltype.handle == h); + let is_mleader_style = |h| matches!(document.objects.get(&h), Some(ObjectType::MultiLeaderStyle(_))); + handle_exists(value.style_handle, "style", &is_mleader_style)?; + handle_exists(value.text_style_handle, "text style", &is_text_style)?; + handle_exists(value.context.text_style_handle, "context text style", &is_text_style)?; + handle_exists(value.line_type_handle, "line type", &is_linetype)?; + handle_exists(value.arrowhead_handle, "arrowhead block", &is_block)?; + handle_exists(value.block_content_handle, "block content", &is_block)?; + handle_exists(value.context.block_content_handle, "context block content", &is_block)?; + } if let EntityType::Dimension(value) = entity { let name = value.base().style_name.trim(); if !document.dim_styles.iter().any(|style| style.name.eq_ignore_ascii_case(name)) { @@ -1540,6 +1645,7 @@ mod tests { | "Leader" | "MLine" | "Dimension" + | "MultiLeader" ) { assert!( @@ -1729,6 +1835,38 @@ mod tests { "Dimension.is_ordinate_type_x".to_owned(), "Dimension.is_partial".to_owned(), "Dimension.has_leader".to_owned(), + "MultiLeader.content_type".to_owned(), + "MultiLeader.path_type".to_owned(), + "MultiLeader.line_color".to_owned(), + "MultiLeader.line_weight".to_owned(), + "MultiLeader.enable_landing".to_owned(), + "MultiLeader.enable_dogleg".to_owned(), + "MultiLeader.dogleg_length".to_owned(), + "MultiLeader.arrowhead_size".to_owned(), + "MultiLeader.text_color".to_owned(), + "MultiLeader.text_frame".to_owned(), + "MultiLeader.text_left_attachment".to_owned(), + "MultiLeader.text_right_attachment".to_owned(), + "MultiLeader.text_top_attachment".to_owned(), + "MultiLeader.text_bottom_attachment".to_owned(), + "MultiLeader.text_attachment_direction".to_owned(), + "MultiLeader.text_attachment_point".to_owned(), + "MultiLeader.text_alignment".to_owned(), + "MultiLeader.text_angle_type".to_owned(), + "MultiLeader.text_direction_negative".to_owned(), + "MultiLeader.scale_factor".to_owned(), + "MultiLeader.enable_annotation_scale".to_owned(), + "MultiLeader.extend_leader_to_text".to_owned(), + "MultiLeader.block_content_color".to_owned(), + "MultiLeader.block_connection_type".to_owned(), + "MultiLeader.block_rotation".to_owned(), + "MultiLeader.block_scale".to_owned(), + "MultiLeader.context".to_owned(), + "MultiLeader.style_handle".to_owned(), + "MultiLeader.text_style_handle".to_owned(), + "MultiLeader.arrowhead_handle".to_owned(), + "MultiLeader.line_type_handle".to_owned(), + "MultiLeader.block_content_handle".to_owned(), ]) ); } diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c5b9a53370157b9e36dbb4a0a4f3018074a3b5a7 GIT binary patch literal 6148 zcmeHK&x;c=6n@#(-R>@8S^Tlb0R{I^aobXK@iLATVe#aM9#ra#GjvC%lcCdY52Y|i zQ3TKaA>u(iiWje5{YwPjOH!R!$E_C;&4c9206Z8$1RRnDYtvyEFbw={4A9=)z+F4WeaoS+f8&1BeZ&W;3^>~< zvm5Yk*N?f6`r3`t&il7JAFh_$220zf2gUhcQWz&7g%~64;|Y_%1&lbvh^dcGoluXs z`TA&&V(gt4@4oEI*pHZJz`tj)yqBI&PWf#8Iv>pN5L$Xx=gutaSIq6z)4iLI&x>_g zYR+PYzw4{#X;Yr7FvOMeAi&iM{YjRp-^&|6jiYR|yZec)RI0~T*HAXs9=Hd5;*Px0 zICH$=J^H%MdF;>Io_{9_C%yW~YdjfwQ4$V?LllH)a{q3W1bpJ~aS~+0*OiKOlwN&% zI_)%D4Xf3jHLPj#QoGZz_S%OF}E34caGWgdf@$bP~q${FyW$KB+#8ZUyma?-I<`Shl zF!lIgYG$ThC``FN+2s<23s&kn77eze=o literal 0 HcmV?d00001 diff --git a/docs/Geometric Constraint Reference.rtf b/docs/Geometric Constraint Reference.rtf new file mode 100644 index 000000000..b3e9d3c5e --- /dev/null +++ b/docs/Geometric Constraint Reference.rtf @@ -0,0 +1,41 @@ +{\rtf1\ansi\ansicpg1252\cocoartf2907 +\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 SFProText-Regular;} +{\colortbl;\red255\green255\blue255;} +{\*\expandedcolortbl;;} +\paperw11900\paperh16840\margl1440\margr1440\vieww28720\viewh12800\viewkind0 +\pard\tx720\tx1440\tx2160\tx2880\tx3600\tx4320\tx5040\tx5760\tx6480\tx7200\tx7920\tx8640\pardirnatural\partightenfactor0 + +\f0\fs36 \cf0 Geometric \'97 select entities, then run\ +Constraint Command AutoCAD-native? DWG class\ +Horizontal HCONSTRAINT \uc0\u9989 Native ACHORIZONTALCONSTRAINT\ +Vertical VCONSTRAINT \uc0\u9989 Native ACVERTICALCONSTRAINT\ +Fixed FXCONSTRAINT \uc0\u9989 Native ACFIXEDCONSTRAINT\ +Parallel PCONSTRAINT \uc0\u9989 Native ACPARALLELCONSTRAINT\ +Perpendicular QCONSTRAINT \uc0\u9989 Native ACPERPENDICULARCONSTRAINT\ +Equal ECONSTRAINT \uc0\u9989 Native ACEQUALLENGTHCONSTRAINT (lines) / ACEQUALRADIUSCONSTRAINT (circles/arcs)\ +Tangent TCONSTRAINT \uc0\u9989 Native ACTANGENTCONSTRAINT\ +Colinear LCONSTRAINT \uc0\u9989 Native ACCOLINEARCONSTRAINT\ +Normal NRCONSTRAINT \uc0\u9989 Native ACNORMALCONSTRAINT\ +Concentric NCONSTRAINT \uc0\u9989 Native ACCONCENTRICCONSTRAINT\ +Symmetric SYCONSTRAINT \uc0\u9989 Native ACSYMMETRICCONSTRAINT\ +\ +Geometric \'97 pick points directly\ +Constraint Command AutoCAD-native? DWG class\ +Coincident CCONSTRAINT \uc0\u9989 Native ACPOINTCOINCIDENCECONSTRAINT\ +Equal Distance EDCONSTRAINT \uc0\u9989 Native ACEQUALDISTANCECONSTRAINT (AutoCAD's 4th Equal sub-kind)\ +\ +Geometric \'97 select target, then pick a point\ +Constraint Command AutoCAD-native? DWG class\ +Center Point CPCONSTRAINT \uc0\u9989 Native ACCENTERPOINTCONSTRAINT\ +Midpoint MPCONSTRAINT \uc0\u9989 Native ACMIDPOINTCONSTRAINT\ +Point on Curve OCCONSTRAINT \uc0\u9989 Native ACPOINTCURVECONSTRAINT\ +\ +Dimensional \'97 select target, type a value/parameter\ +Constraint Command AutoCAD-native? DWG class\ +Distance DCONSTRAINT \uc0\u9989 Native ACDISTANCECONSTRAINT\ +DistanceX DCONSTRAINT \uc0\u8594 X \u9989 Native ACDISTANCECONSTRAINT (fixed X direction byte)\ +DistanceY DCONSTRAINT \uc0\u8594 Y \u9989 Native ACDISTANCECONSTRAINT (fixed Y direction byte)\ +Radius DCONSTRAINT \uc0\u9989 Native ACRADIUSDIAMETERCONSTRAINT\ +Diameter DCONSTRAINT \uc0\u8594 D \u9989 Native ACRADIUSDIAMETERCONSTRAINT (mode byte)\ +Angle ACONSTRAINT \uc0\u9989 Native ACANGLECONSTRAINT\ +Arc Length DCONSTRAINT \uc0\u8594 A \u10060 OCS-only none \'97 confirmed against AcExplicitConstr.h; no ArcLength-shaped class exists in ObjectARX at all} \ No newline at end of file diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 049a4db14..2fe0ff894 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **25 of 43** canvas kinds: 24 -are creatable and `Insert` remains update-only. This is **not** 25 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine and Dimension pass every completion gate. +v7. The Python feature has explicit writes for **26 of 43** canvas kinds: 25 +are creatable and `Insert` remains update-only. This is **not** 26 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension and MultiLeader pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 18 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 17 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **MultiLeader** (queue item 4). Leader is done except for its W +Continue with **Table** (queue item 5). Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -160,3 +160,4 @@ exact queue above and update statuses with evidence after each increment. - Mapped `Leader` (host validation and reference binding in `entity_coverage.rs`, policy/build.rs/manifest entries, adapter count 23). `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P through the real runner. W is blocked by acadrust: the DXF writer omits the annotation handle (340), DXF reads lose `annotation_offset` (213), and DWG R2010+ does not store text size or hookline flag. Each gap is pinned by a canary assertion so it fails loudly once fixed. The queue now continues with `MLine`. - Completed `MLine`. Host validation and style binding live in `ocs_plugin_api`; derived vertex geometry and element parameters are recomputed by the host (`normalize_scripted_mline`) on scripted create/edit, so scripts supply positions only. The adapter maps `flags` as an integer through a manifest override because the generator cannot classify acadrust's serde-newtype `MLineFlags`. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen. The queue now continues with `Dimension`. - Completed `Dimension` for all nine subtypes. Because the payload is an enum, the adapter generator gained a `manual_kinds` hook (hand-written `_to_dict/_from_dict/_apply`) and the host catalog a `synthetic` policy section. The host derives `actual_measurement` and the base definition point on every scripted create/edit. Python ints are now accepted wherever a coordinate or angle is expected. `staged_python_dimension_lifecycle_over_real_ipc` records each subtype separately and all nine pass C/R/E/D/U/I/W/V/P. The queue now continues with `MultiLeader`. +- Completed `MultiLeader`. The adapter generator now converts `Color`, `LineWeight`, mixed unit/payload enums, fixed float arrays and bitflags-2 newtypes, and a `keep_types` override lets a hand-written setter reuse generated converters (used for the merge-style `context` patch). `Leader.override_color` became writable through the same `Color` form; neither file format stores it. `staged_python_multileader_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `Table`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 73bbd1d91..f434b3510 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **25 of 43 canvas kinds have explicit geometry writes** (24 creatable, plus update-only `Insert`), **6 kinds are complete**, and 18 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **26 of 43 canvas kinds have explicit geometry writes** (25 creatable, plus update-only `Insert`), **7 kinds are complete**, and 17 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -31,7 +31,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Leader | **Integration-tested / blocked at W by acadrust** | Fields mapped writable: `vertices` (≥2 finite points, not all coincident), `annotation_handle`, `creation_type`, `path_type`, `dimension_style`, `arrow_enabled`, `hookline_enabled`, `hookline_direction`, `text_height`, `text_width`, `normal`, `horizontal_direction`, `block_offset`, `annotation_offset`. Read-only: `origin`, `dimension_gap`, `arrowhead_type`, `arrow_size`, `byblock_color`. `override_color` is writable through the new generator `Color` form (`{'kind': 'Rgb', 'value': {'r','g','b'}}`, `Index`, `ByLayer`, `ByBlock`, `None`). Unmapped but preserved on edit: the `dwg_unknown_*` fields. Host reference binding validates that `annotation_handle` exists, is not the leader itself, matches `creation_type` (Text/MText, Tolerance, Insert; `NoAnnotation` forbids a handle) and that `dimension_style` exists; nothing is silently defaulted. `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P against acadrust `7ea4247`: linked Text annotation, path/height/arrow/offset transaction edit with untouched-field preservation, GUI pick grouping (scripted selection stays exact), seven single-edit rejections plus a cross-entity atomic rollback, four-step undo/redo, real render linework, DWG and DXF reopen and re-edit. Erasing a leader also erases its annotation (scene leader grouping); undo restores both. **W blockers (engine, recorded as canary assertions in the test):** (1) the DXF writer never emits group 340, so a DXF save unlinks the annotation; (2) DXF reads drop `annotation_offset` (group 213 is written but not reassembled); (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (acadrust writes them only for R13–R2007 / R13–R14); (4) neither writer stores `override_color`, so it reopens as `ByLayer` in DWG and DXF. Not `Complete` until the engine round-trips (1) and (2); (3) needs a decision to document or make read-only. | | MLine | **Complete** | Writable: `vertices` (raw vertex dicts; a script may pass only `position`), `flags` (integer, unknown bits rejected), `justification`, `normal`, `scale_factor`, `style_name`. Read-only: `start_point`, `style_handle`, `style_element_count`. Host validation requires ≥2 finite non-coincident vertices, ≥3 for a closed multiline, a finite nonzero scale and normal, an existing non-empty `MLineStyle`, and per-vertex segments that are either empty or match the style element count. Vertex `direction`/`miter`, segment layout and per-element miter offsets (justification and scale included) are host-derived by `normalize_scripted_mline` on every scripted create or edit, reusing the drawing command's `rebuild_mline_geometry`/`sync_mline_element_parameters`; dash and fill cuts survive an edit unless a vertex moves. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: create against the Standard style, a three-vertex/scale/justification transaction edit with untouched-field preservation, corner miter widening and element offsets checked, real `mline_lines` linework before and after DWG and DXF reopen, nine single-edit rejections plus a cross-entity atomic rollback, re-edit after reopen in both formats, deletion, and three-step undo/redo. Known limit: switching to a style with a different element count is rejected until the script also clears the vertices' `segments`; only the rejection is covered by the integration test. | | Dimension | **Complete** (all 9 subtypes) | `EntityType::Dimension` wraps a nine-variant enum, so the adapter uses a hand-written flat model (`dimension_model.rs.inc`, wired in through the manifest's `manual_kinds`) and the coverage catalog lists its properties through the policy's `synthetic` section. Python sees `subtype` (fixed at creation), the shared base fields (`text`, `style_name`, `normal`, `text_middle_point`, `attachment_point`, `text_rotation`, `horizontal_direction`, `flip_arrow1/2`, `text_user_positioned`) and only the fields of that subtype; a field from another subtype is rejected, not ignored. `actual_measurement` and the base definition point are host-derived (`normalize_scripted_dimension`, the same recompute grip edits use). Unmapped but preserved: `user_text`, `insertion_point`, `block_name`, version and DWG flags, style overrides in XDATA. Per-subtype status, all verified by `staged_python_dimension_lifecycle_over_real_ipc` (C/R/E/D/U/I/W/V/P; measurement and canvas wires checked for each, DWG and DXF reopen recorded per subtype): Linear (10), Aligned (5), Radius (5), Diameter (10), Angular3Pt (90°), Angular2Ln (90°), Ordinate (5), Arc (5·π/2), LargeRadial (6) — every one creates, reads, derives the right measurement, renders wires, and keeps its measurement through DWG and DXF. Edits (Linear second point/text/attachment, Radius definition point) update the measurement, nine rejection cases plus a cross-entity atomic rollback and three create-time rejections leave the document untouched, and post-reopen re-edit passes in both formats. Not covered: text formatting overrides, arrowheads and other DIMSTYLE overrides stored in XDATA, the anonymous `*D` block. The subtype-specific field meanings for the angular subtypes follow the DXF group-code layout rather than the acadrust convenience constructors. | -| MultiLeader | Layer-only | Requires nested context and style fixture. | +| MultiLeader | **Complete** | Mapped through the generic generator, which gained support for `Color`, `LineWeight`, mixed unit/payload enums, fixed `[f64; N]` arrays, bitflags-2 newtypes and per-type module paths for this kind. Writable: `content_type`, `path_type`, `line_color`, `line_weight`, `enable_landing`, `enable_dogleg`, `dogleg_length`, `arrowhead_size`, `text_color`, `text_frame`, the four text attachments, attachment direction/point, `text_alignment`, `text_angle_type`, `text_direction_negative`, `scale_factor`, `enable_annotation_scale`, `extend_leader_to_text`, block content fields, the reference handles (`style_handle`, `text_style_handle`, `arrowhead_handle`, `line_type_handle`, `block_content_handle`) and `context`, which merges as a patch so a script can change `text_string` without resetting leader roots. Read-only: `dwg_version`, `property_override_flags`, `text_align_in_ipe` and the entity-level `text_height` (neither writer stores it; set `context.text_height`). Excluded but preserved: `arrowhead_overrides`, `block_attributes`. Host validation checks finite/positive scale, height, dogleg and arrow sizes, finite context geometry and transform, non-empty leader lines, content-type/context consistency, and that every referenced handle exists as the right object kind. `staged_python_multileader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: MText creation with a three-point leader root, a partial-context edit that replaces the path (four points), text, text location, dogleg and colour while preserving scale/content/common fields, canvas wires, eleven rejection cases plus a duplicate-handle atomic rollback, DWG and DXF reopen (text, path, dogleg, `context.text_height`, colour) with post-reopen re-edit, and three-step undo/redo. Not covered: block-content MultiLeaders (no block fixture yet), tolerance content, and style-defined defaults. | | Table | Layer-only | Requires table style and cell fixture. | | PolygonMesh | Layer-only | Requires consistent grid/count fixture. | | PolyfaceMesh | Layer-only | Requires valid face-index fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 76adad3b3..af44b53b4 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 25 geometry converters. Other writable properties +canvas kinds outside its 26 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 25-kind generated schema it contains only handle, kind, and layer; +outside the 26-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 6bdb400c6..3ea5ffde3 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 25 mapped kinds. +the 26 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index 9affcca6e..df42470e9 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -89,6 +89,11 @@ pub struct FieldOverride { /// already-valid entity never needs it re-specified). #[serde(default)] pub required: bool, + /// Still generate this field type's converters although a + /// `rust_getter`/`rust_setter` override replaces the generic path (the + /// override's hand-written code calls them). + #[serde(default)] + pub keep_types: bool, } impl Manifest { @@ -141,7 +146,7 @@ fn classify<'a>(name: &str, info: &'a TypeInfo) -> Classify { } TypeKind::Enum => { if info.variants.iter().any(|v| !v.fields.is_empty()) { - if info.variants.iter().all(|v| v.fields.len() == 1) { + if info.variants.iter().all(|v| v.fields.len() <= 1) { Classify::TaggedEnum } else { panic!("entity_manifest.json: {name} has a tagged variant without exactly one payload"); @@ -150,6 +155,9 @@ fn classify<'a>(name: &str, info: &'a TypeInfo) -> Classify { Classify::UnitEnum } } + // acadrust `bitflags!` types serialize as a newtype over their integer + // bits; they convert exactly like the hand-written `bits` structs. + TypeKind::Newtype => Classify::BitFlags, other => panic!("entity_manifest.json: {name} has unsupported registry kind {other:?}"), } } @@ -181,7 +189,7 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec { } if override_def .and_then(|o| o.fields.get(&f.name)) - .map(|o| o.exclude || o.rust_getter.is_some()) + .map(|o| o.exclude || (o.rust_getter.is_some() && !o.keep_types)) .unwrap_or(false) { // Excluded, or fully hand-supplied by the override — its @@ -193,7 +201,10 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec { } while let Some(name) = queue.pop() { - if BUILTIN.contains(&name.as_str()) || SPECIAL.contains(&name.as_str()) { + if BUILTIN.contains(&name.as_str()) + || SPECIAL.contains(&name.as_str()) + || f64_array_len(&name).is_some() + { continue; } if !needed.insert(name.clone()) { @@ -276,11 +287,32 @@ fn native_rust_type(type_id: &str) -> &'static str { } } +/// Rust path of a traced type; most live in `acadrust::entities`. +fn type_path(name: &str) -> String { + match name { + "LineWeight" => "acadrust::types::LineWeight".to_owned(), + "LeaderLineBreakInfo" => "acadrust::entities::multileader::LeaderLineBreakInfo".to_owned(), + _ => format!("acadrust::entities::{name}"), + } +} + fn is_native(type_id: &str) -> bool { BUILTIN.contains(&type_id) } +/// `[f64; N]` fixed arrays (e.g. a 4x4 transform) convert as flat float lists. +fn f64_array_len(type_id: &str) -> Option { + type_id + .strip_prefix("[f64; ")? + .strip_suffix(']')? + .parse() + .ok() +} + fn default_expr(type_id: &str) -> String { + if let Some(n) = f64_array_len(type_id) { + return format!("[0.0f64; {n}]"); + } match type_id { "f64" | "f32" => "0.0".to_string(), "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => "0".to_string(), @@ -302,6 +334,11 @@ fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String // call or cast directly on `item_expr` (`item_expr.method()`, // `item_expr as T`) does, since `&` binds looser than both. let parenthesized = format!("({item_expr})"); + if f64_array_len(type_id).is_some() { + return format!( + "Ok(vm.ctx.new_list(({item_expr}).iter().map(|item| vm.new_pyobj(*item)).collect()).into())" + ); + } match type_id { "f64" | "f32" | "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "bool" => { format!("Ok(vm.new_pyobj(*{parenthesized}))") @@ -331,6 +368,9 @@ fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> Str if is_native(type_id) { return format!("{value_expr}.try_into_value::<{}>(vm)", native_rust_type(type_id)); } + if let Some(n) = f64_array_len(type_id) { + return format!("py_to_f64_array::<{n}>({value_expr}, vm)"); + } match type_id { "Vector3" => format!("py_to_vector3_dict({value_expr}, vm)"), "Vector2" => format!("py_to_vector2({value_expr}, vm)"), @@ -346,11 +386,18 @@ fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> Str ), Classify::TaggedEnum => format!("dict_to_{}({value_expr}, vm)", snake_case(other)), Classify::BitFlags => { - let path = format!("acadrust::entities::{other}"); + let path = type_path(other); let bits_ty = bits_field_type(info); - format!( - "{value_expr}.try_into_value::(vm).and_then(|n| <{bits_ty}>::try_from(n).map({path}::from_bits).map_err(|_| vm.new_value_error(\"{other} bits out of range\".to_owned())))" - ) + if matches!(info.kind, TypeKind::Newtype) { + // bitflags 2: `from_bits` is fallible and rejects unknown bits. + format!( + "{value_expr}.try_into_value::(vm).and_then(|n| <{bits_ty}>::try_from(n).ok().and_then({path}::from_bits).ok_or_else(|| vm.new_value_error(\"{other} has unknown or out-of-range bits\".to_owned())))" + ) + } else { + format!( + "{value_expr}.try_into_value::(vm).and_then(|n| <{bits_ty}>::try_from(n).map({path}::from_bits).map_err(|_| vm.new_value_error(\"{other} bits out of range\".to_owned())))" + ) + } } Classify::Struct => format!("dict_to_{}({value_expr}, vm)", snake_case(other)), } @@ -371,6 +418,8 @@ fn getter_for_field( is_sequence: bool, field_place: &str, ) -> String { + // The registry reports a fixed array as a sequence of itself. + let is_sequence = is_sequence && f64_array_len(type_id).is_none(); if is_sequence { let inner = leaf_to_py(registry, type_id, "item"); format!( @@ -398,6 +447,7 @@ fn setter_for_field( py_name: &str, keep_expr: &str, ) -> String { + let is_sequence = is_sequence && f64_array_len(type_id).is_none(); if is_sequence { let inner = leaf_from_py(registry, type_id, "item"); format!( @@ -417,7 +467,7 @@ fn setter_for_field( } fn field_default_expr(f: &FieldInfo) -> String { - if f.is_sequence { + if f.is_sequence && f64_array_len(f.type_id.as_str()).is_none() { "Vec::new()".to_string() } else if f.optional { "None".to_string() @@ -451,7 +501,7 @@ fn wrap_required_nonempty(setter_expr: &str, entity_kind: &str, py_name: &str) - // ════════════════════════════════════════════════════════════════════════════ fn gen_unit_enum(name: &str, info: &TypeInfo) -> String { - let path = format!("acadrust::entities::{name}"); + let path = type_path(name); let snake = snake_case(name); let mut name_arms = String::new(); let mut parse_arms = String::new(); @@ -486,13 +536,14 @@ fn default_{snake}() -> {path} {{ ) } -fn gen_bitflags(name: &str) -> String { - let path = format!("acadrust::entities::{name}"); +fn gen_bitflags(name: &str, info: &TypeInfo) -> String { + let path = type_path(name); let snake = snake_case(name); + let ctor = if matches!(info.kind, TypeKind::Newtype) { "empty" } else { "new" }; format!( r#"#[allow(dead_code)] fn default_{snake}() -> {path} {{ - {path}::new() + {path}::{ctor}() }} "# @@ -500,11 +551,22 @@ fn default_{snake}() -> {path} {{ } fn gen_tagged_enum(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String { - let path = format!("acadrust::entities::{name}"); + let path = type_path(name); let snake = snake_case(name); let mut to_arms = String::new(); let mut from_arms = String::new(); for variant in &info.variants { + if variant.fields.is_empty() { + to_arms.push_str(&format!( + " {path}::{variant} => {{ let dict = vm.ctx.new_dict(); dict.set_item(\"kind\", vm.new_pyobj(\"{variant}\"), vm)?; Ok(dict.into()) }}\n", + variant = variant.name, + )); + from_arms.push_str(&format!( + " \"{variant}\" => Ok({path}::{variant}),\n", + variant = variant.name, + )); + continue; + } let payload = &variant.fields[0]; let getter = leaf_to_py(registry, payload.type_id.as_str(), "value"); let setter = leaf_from_py(registry, payload.type_id.as_str(), "payload"); @@ -518,7 +580,10 @@ fn gen_tagged_enum(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> Stri )); } let first = &info.variants[0]; - let default_payload = default_expr(first.fields[0].type_id.as_str()); + let default_value = match first.fields.first() { + None => format!("{path}::{}", first.name), + Some(payload) => format!("{path}::{}({})", first.name, default_expr(payload.type_id.as_str())), + }; format!( r#"fn {snake}_to_dict(vm: &VirtualMachine, value: &{path}) -> PyResult {{ match value {{ @@ -535,16 +600,15 @@ fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}> #[allow(dead_code)] fn default_{snake}() -> {path} {{ - {path}::{first_variant}({default_payload}) + {default_value} }} -"#, - first_variant = first.name, +"# ) } fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String { - let path = format!("acadrust::entities::{name}"); + let path = type_path(name); let snake = snake_case(name); let mut to_dict_body = String::new(); let mut from_dict_fields = String::new(); @@ -910,7 +974,7 @@ pub fn generate_entity_crud(manifest: &Manifest, registry: &TypeRegistry) -> Str match classify(&name, info) { Classify::UnitEnum => out.push_str(&gen_unit_enum(&name, info)), Classify::TaggedEnum => out.push_str(&gen_tagged_enum(&name, info, registry)), - Classify::BitFlags => out.push_str(&gen_bitflags(&name)), + Classify::BitFlags => out.push_str(&gen_bitflags(&name, info)), Classify::Struct => out.push_str(&gen_struct(&name, info, registry)), } } diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 2799c4a14..d9377cf0f 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -24,7 +24,8 @@ "Hatch", "Leader", "MLine", - "Dimension" + "Dimension", + "MultiLeader" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -264,6 +265,21 @@ "dwg_unknown_bit5": { "exclude": true }, "dwg_unknown_short1": { "exclude": true } } + }, + "MultiLeader": { + "fields": { + "arrowhead_overrides": { "exclude": true, "_comment": "Per-arrow override records carry linked block handles; preserved untouched until dedicated validation exists." }, + "block_attributes": { "exclude": true, "_comment": "Block attribute records carry attribute-definition handles; preserved untouched." }, + "dwg_version": { "read_only": true }, + "text_height": { "read_only": true, "_comment": "Neither writer stores the entity-level text height; scripts set the persisted context.text_height." }, + "property_override_flags": { "read_only": true }, + "text_align_in_ipe": { "read_only": true }, + "context": { + "keep_types": true, + "rust_getter": "multi_leader_annot_context_to_dict(vm, &VAR.context)?", + "rust_setter": "if let Some(v) = dict.get_item_opt(\"context\", vm)? { VAR.context = merge_multileader_context(&VAR.context, v, vm)?; }" + } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 3ba9dd921..f706c947a 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -3188,6 +3188,18 @@ mod ocs { }) } + fn py_to_f64_array(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<[f64; N]> { + let items: Vec = value.try_into_value(vm)?; + if items.len() != N { + return Err(vm.new_value_error(format!("expected {N} numbers, got {}", items.len()))); + } + let mut out = [0.0; N]; + for (slot, item) in out.iter_mut().zip(items) { + *slot = py_number_to_f64(item, vm)?; + } + Ok(out) + } + /// Accept a Python int or float; `try_into_value::` rejects ints. fn py_number_to_f64(value: PyObjectRef, vm: &VirtualMachine) -> PyResult { Ok(value.try_float(vm)?.to_f64()) @@ -3249,6 +3261,7 @@ mod ocs { include!(concat!(env!("OUT_DIR"), "/entity_crud.rs")); include!("dimension_model.rs.inc"); + include!("patch_helpers.rs.inc"); // ════════════════════════════════════════════════════════════════════ // Phase 1 — wiring the generic conversion functions above into `ocs`. @@ -4743,7 +4756,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 25); + assert_eq!(emitted.len(), 26); } #[cfg(feature = "experimental-host-model")] diff --git a/plugins/opencad-python/src/patch_helpers.rs.inc b/plugins/opencad-python/src/patch_helpers.rs.inc new file mode 100644 index 000000000..21a054803 --- /dev/null +++ b/plugins/opencad-python/src/patch_helpers.rs.inc @@ -0,0 +1,23 @@ +// Merge helpers for nested structs that scripts edit as a partial patch. + +/// Apply `patch` (a dict of context fields) onto `existing`, so a script can +/// change one context field without resetting the rest. Unknown keys error. +fn merge_multileader_context( + existing: &acadrust::entities::MultiLeaderAnnotContext, + patch: PyObjectRef, + vm: &VirtualMachine, +) -> PyResult { + let patch = patch.try_into_value::(vm)?; + let merged = multi_leader_annot_context_to_dict(vm, existing)? + .try_into_value::(vm)?; + for key in patch.keys_vec() { + let name = key.clone().try_into_value::(vm)?; + if merged.get_item_opt(name.as_str(), vm)?.is_none() { + return Err(vm.new_value_error(format!( + "ocs: MultiLeader.context property {name:?} is outside the editable schema" + ))); + } + merged.set_item(name.as_str(), patch.get_item(name.as_str(), vm)?, vm)?; + } + dict_to_multi_leader_annot_context(merged.into(), vm) +} diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index f968b3999..19a0a3aea 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -2783,6 +2783,160 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(linear).is_none()); } + #[test] + fn staged_python_multileader_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + + let script = std::env::temp_dir().join(format!("ocs_mleader_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "def P(x, y): return {'x': x, 'y': y, 'z': 0.0}\n", + "doc = ocs.active_document\n", + "doc.create_entity('MultiLeader', content_type='MText', context={\n", + " 'has_text_contents': True, 'text_string': 'NOTE', 'text_location': P(20, 10),\n", + " 'text_height': 2.5,\n", + " 'leader_roots': [{'connection_point': P(15, 10), 'direction': P(1, 0), 'landing_distance': 2.0,\n", + " 'lines': [{'points': [P(0, 0), P(10, 10), P(15, 10)]}]}]})\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let handle = host.document().entities().find_map(|entity| match entity { + EntityType::MultiLeader(value) => Some(value.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create MultiLeader: {}", last_output(&host))); + let created = host.document().get_entity(handle).unwrap().clone(); + let EntityType::MultiLeader(created_ml) = &created else { unreachable!() }; + assert_eq!(created_ml.context.text_string, "NOTE"); + assert_eq!(created_ml.context.leader_roots[0].lines[0].points.len(), 3); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); + + let script = std::env::temp_dir().join(format!("ocs_mleader_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y): return {{'x': x, 'y': y, 'z': 0.0}}\n", + "doc = ocs.active_document\n", + "ml = doc.entities[{}]\n", + "roots = ml.context['leader_roots']\n", + "roots[0]['lines'][0]['points'] = [P(0, 0), P(12, 12), P(18, 12), P(22, 12)]\n", + "with doc.transaction('Edit multileader'):\n", + " ml.context = {{'leader_roots': roots, 'text_string': 'EDITED', 'text_location': P(26, 12), 'text_height': 3.0}}\n", + " ml.dogleg_length = 3.0\n", + " ml.line_color = {{'kind': 'Rgb', 'value': {{'r': 0, 'g': 128, 'b': 255}}}}\n", + "doc.selection = [ml]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::MultiLeader(edited) = &expected else { unreachable!() }; + assert_eq!(edited.context.text_string, "EDITED", "edit failed: {}", last_output(&host)); + assert_eq!(edited.context.leader_roots[0].lines[0].points.len(), 4); + assert_eq!(edited.dogleg_length, 3.0); + assert_eq!(edited.context.text_height, 3.0); + assert_eq!(edited.text_height, created_ml.text_height); + assert_eq!(edited.line_color, acadrust::types::Color::Rgb { r: 0, g: 128, b: 255 }); + // Untouched context fields and the entity identity survive a partial patch. + assert_eq!(edited.common, created_ml.common); + assert_eq!(edited.context.scale_factor, created_ml.context.scale_factor); + assert_eq!(edited.content_type, created_ml.content_type); + assert_eq!(edited.context.leader_roots[0].connection_point, created_ml.context.leader_roots[0].connection_point); + assert_eq!(host.selection(), vec![handle]); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty()); + + for (patch, message) in [ + ("'text_height':0.0", "read-only"), + ("'scale_factor':-1.0", "greater than zero"), + ("'context':{'text_height':float('inf')}", "finite"), + ("'style_handle':999999", "does not exist"), + ("'text_style_handle':999999", "does not exist"), + ("'content_type':'None'", "cannot carry"), + ("'context':{'nonsense':1}", "outside the editable schema"), + ("'context':{'leader_roots':[{'lines':[{'points':[]}]}]}", "has no points"), + ("'context':{'text_location':{'x':float('nan'),'y':0.0,'z':0.0}}", "finite"), + ("'dwg_version':5", "read-only"), + ("'line_color':{'kind':'Index','value':300}", "Color"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject multileader', [{{'handle':{}, {patch}}}])", handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'dogleg_length':9.0}},", + "{{'handle':{},'scale_factor':-1.0}}])" + ), handle.value(), handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + + let mut gaps = Vec::new(); + let dwg = crate::io::load_bytes("mleader.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("mleader.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + let Some(EntityType::MultiLeader(value)) = document.get_entity(handle) else { + gaps.push(format!("{format}: MultiLeader missing")); + continue; + }; + if value.context.text_string != "EDITED" { gaps.push(format!("{format}: text {:?}", value.context.text_string)); } + let points = value.context.leader_roots.first().and_then(|r| r.lines.first()).map_or(0, |l| l.points.len()); + if points != 4 { gaps.push(format!("{format}: leader points {points}")); } + if value.dogleg_length != 3.0 { gaps.push(format!("{format}: dogleg {}", value.dogleg_length)); } + if value.context.text_height != edited.context.text_height { gaps.push(format!("{format}: context.text_height {}", value.context.text_height)); } + if value.line_color != edited.line_color { gaps.push(format!("{format}: line_color {:?}", value.line_color)); } + } + assert!(gaps.is_empty(), "persistence gaps: {gaps:#?}"); + for (name, mut document) in [("mleader-reedit.dwg", dwg), ("mleader-reedit.dxf", dxf)] { + let before = document.get_entity(handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::MultiLeader(value) = &mut after else { unreachable!() }; + value.dogleg_length = 4.5; + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + *document.get_entity_mut(handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle), + Some(EntityType::MultiLeader(value)) if value.dogleg_length == 4.5), "{name}"); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + for (name, bytes) in [ + ("mleader-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("mleader-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 0206c7c30b1dc77864f7a25729d7d57020e0f3e6 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 14:36:12 +0200 Subject: [PATCH 22/76] Complete Table host model coverage Maps rows, columns, merged ranges, style and placement through the generic generator (now with tuple and usize support), adds grid/merge/reference validation, and keeps DXF cell merge dimensions in step with merged_ranges. staged_python_table_lifecycle_over_real_ipc passes the full gate including DWG and DXF reopen. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 30 ++++ crates/ocs_plugin_api/src/entity_coverage.rs | 113 ++++++++++++ docs/plugin-host-model-completion-plan.md | 11 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/build/generate.rs | 52 ++++++ plugins/opencad-python/entity_manifest.json | 32 +++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 163 ++++++++++++++++++ src/entities/table.rs | 26 +++ 12 files changed, 428 insertions(+), 12 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 985ddedef..3af908d70 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -786,6 +786,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | "annotation_offset" ) | ("MultiLeader", _) + | ("Table", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 5fb91e8a8..145a7197e 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -331,6 +331,19 @@ "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" ] }, "readable": { @@ -363,6 +376,23 @@ "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" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index e569f41fe..f5debad44 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -548,6 +548,80 @@ fn validate_multileader( Ok(()) } +/// Field checks shared by Table creation and mutation. +#[cfg(feature = "host")] +fn validate_table( + old: Option<&crate::host::acadrust::entities::Table>, + new: &crate::host::acadrust::entities::Table, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + finite_vector("Table.insertion_point", &new.insertion_point)?; + solid_normal("Table.normal", &new.normal)?; + solid_normal("Table.horizontal_direction", &new.horizontal_direction)?; + if !new.break_spacing.is_finite() || new.break_spacing < 0.0 { + return Err("Table.break_spacing must be finite and non-negative".into()); + } + if new.rows.is_empty() || new.columns.is_empty() { + return Err("Table requires at least one row and one column".into()); + } + for (ci, column) in new.columns.iter().enumerate() { + if !column.width.is_finite() || column.width <= 0.0 { + return Err(format!("Table.columns[{ci}].width must be finite and greater than zero")); + } + } + for (ri, row) in new.rows.iter().enumerate() { + if !row.height.is_finite() || row.height <= 0.0 { + return Err(format!("Table.rows[{ri}].height must be finite and greater than zero")); + } + if row.cells.len() != new.columns.len() { + return Err(format!( + "Table.rows[{ri}] has {} cells but the table has {} columns", + row.cells.len(), + new.columns.len() + )); + } + for (ci, cell) in row.cells.iter().enumerate() { + if !cell.rotation.is_finite() || !cell.geometry_scale.is_finite() || !cell.block_scale.is_finite() { + return Err(format!("Table.rows[{ri}].cells[{ci}] has non-finite rotation or scale")); + } + for (ki, content) in cell.contents.iter().enumerate() { + if !content.rotation.is_finite() || !content.scale.is_finite() || !content.text_height.is_finite() || content.text_height < 0.0 { + return Err(format!( + "Table.rows[{ri}].cells[{ci}].contents[{ki}] has invalid rotation, scale or text height" + )); + } + if !content.value.numeric_value.is_finite() { + return Err(format!( + "Table.rows[{ri}].cells[{ci}].contents[{ki}].value.numeric_value must be finite" + )); + } + } + } + } + let (rows, columns) = (new.rows.len(), new.columns.len()); + for (mi, range) in new.merged_ranges.iter().enumerate() { + if range.top_row > range.bottom_row + || range.left_col > range.right_col + || range.bottom_row >= rows + || range.right_col >= columns + { + return Err(format!("Table.merged_ranges[{mi}] lies outside the {rows}x{columns} grid")); + } + for (mj, other) in new.merged_ranges.iter().enumerate().skip(mi + 1) { + let separate = range.bottom_row < other.top_row + || other.bottom_row < range.top_row + || range.right_col < other.left_col + || other.right_col < range.left_col; + if !separate { + return Err(format!("Table.merged_ranges[{mi}] overlaps merged_ranges[{mj}]")); + } + } + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -643,6 +717,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::MLine(value) => validate_mline(None, value)?, EntityType::Dimension(value) => validate_dimension(None, value)?, EntityType::MultiLeader(value) => validate_multileader(None, value)?, + EntityType::Table(value) => validate_table(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -999,6 +1074,7 @@ pub fn validate_entity_mutation( } (EntityType::Dimension(old), EntityType::Dimension(new)) => validate_dimension(Some(old), new)?, (EntityType::MultiLeader(old), EntityType::MultiLeader(new)) => validate_multileader(Some(old), new)?, + (EntityType::Table(old), EntityType::Table(new)) => validate_table(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -1262,6 +1338,32 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::Table(value) = entity { + if let Some(handle) = value.table_style_handle.filter(|handle| !handle.is_null()) { + if !matches!( + document.objects.get(&handle), + Some(crate::host::acadrust::objects::ObjectType::TableStyle(_)) + ) { + return Err(format!("Table style handle {handle:?} does not exist")); + } + } + for (ri, row) in value.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + for content in &cell.contents { + if let Some(handle) = content.text_style_handle.filter(|handle| !handle.is_null()) { + if !document.text_styles.iter().any(|style| style.handle == handle) { + return Err(format!("Table.rows[{ri}].cells[{ci}] text style handle {handle:?} does not exist")); + } + } + if let Some(handle) = content.block_handle.filter(|handle| !handle.is_null()) { + if !document.block_records.iter().any(|record| record.handle == handle) { + return Err(format!("Table.rows[{ri}].cells[{ci}] block handle {handle:?} does not exist")); + } + } + } + } + } + } if let EntityType::MultiLeader(value) = entity { use crate::host::acadrust::objects::ObjectType; let handle_exists = |handle: Option, what: &str, ok: &dyn Fn(crate::host::acadrust::Handle) -> bool| { @@ -1646,6 +1748,7 @@ mod tests { | "MLine" | "Dimension" | "MultiLeader" + | "Table" ) { assert!( @@ -1867,6 +1970,16 @@ mod tests { "MultiLeader.arrowhead_handle".to_owned(), "MultiLeader.line_type_handle".to_owned(), "MultiLeader.block_content_handle".to_owned(), + "Table.insertion_point".to_owned(), + "Table.horizontal_direction".to_owned(), + "Table.normal".to_owned(), + "Table.table_style_handle".to_owned(), + "Table.rows".to_owned(), + "Table.columns".to_owned(), + "Table.merged_ranges".to_owned(), + "Table.break_spacing".to_owned(), + "Table.break_flow_direction".to_owned(), + "Table.break_options".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 2fe0ff894..49033db44 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **26 of 43** canvas kinds: 25 -are creatable and `Insert` remains update-only. This is **not** 26 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension and MultiLeader pass every completion gate. +v7. The Python feature has explicit writes for **27 of 43** canvas kinds: 26 +are creatable and `Insert` remains update-only. This is **not** 27 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader and Table pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 17 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 16 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **Table** (queue item 5). Leader is done except for its W +Continue with **PolygonMesh** (queue item 6). Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -161,3 +161,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `MLine`. Host validation and style binding live in `ocs_plugin_api`; derived vertex geometry and element parameters are recomputed by the host (`normalize_scripted_mline`) on scripted create/edit, so scripts supply positions only. The adapter maps `flags` as an integer through a manifest override because the generator cannot classify acadrust's serde-newtype `MLineFlags`. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen. The queue now continues with `Dimension`. - Completed `Dimension` for all nine subtypes. Because the payload is an enum, the adapter generator gained a `manual_kinds` hook (hand-written `_to_dict/_from_dict/_apply`) and the host catalog a `synthetic` policy section. The host derives `actual_measurement` and the base definition point on every scripted create/edit. Python ints are now accepted wherever a coordinate or angle is expected. `staged_python_dimension_lifecycle_over_real_ipc` records each subtype separately and all nine pass C/R/E/D/U/I/W/V/P. The queue now continues with `MultiLeader`. - Completed `MultiLeader`. The adapter generator now converts `Color`, `LineWeight`, mixed unit/payload enums, fixed float arrays and bitflags-2 newtypes, and a `keep_types` override lets a hand-written setter reuse generated converters (used for the merge-style `context` patch). `Leader.override_color` became writable through the same `Color` form; neither file format stores it. `staged_python_multileader_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `Table`. +- Completed `Table`. The generator now handles tuples and `usize`; the host keeps DXF cell merge dimensions in step with `merged_ranges`. `staged_python_table_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `PolygonMesh`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index f434b3510..a89a4eb88 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **26 of 43 canvas kinds have explicit geometry writes** (25 creatable, plus update-only `Insert`), **7 kinds are complete**, and 17 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **27 of 43 canvas kinds have explicit geometry writes** (26 creatable, plus update-only `Insert`), **8 kinds are complete**, and 16 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -32,7 +32,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | MLine | **Complete** | Writable: `vertices` (raw vertex dicts; a script may pass only `position`), `flags` (integer, unknown bits rejected), `justification`, `normal`, `scale_factor`, `style_name`. Read-only: `start_point`, `style_handle`, `style_element_count`. Host validation requires ≥2 finite non-coincident vertices, ≥3 for a closed multiline, a finite nonzero scale and normal, an existing non-empty `MLineStyle`, and per-vertex segments that are either empty or match the style element count. Vertex `direction`/`miter`, segment layout and per-element miter offsets (justification and scale included) are host-derived by `normalize_scripted_mline` on every scripted create or edit, reusing the drawing command's `rebuild_mline_geometry`/`sync_mline_element_parameters`; dash and fill cuts survive an edit unless a vertex moves. `staged_python_mline_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: create against the Standard style, a three-vertex/scale/justification transaction edit with untouched-field preservation, corner miter widening and element offsets checked, real `mline_lines` linework before and after DWG and DXF reopen, nine single-edit rejections plus a cross-entity atomic rollback, re-edit after reopen in both formats, deletion, and three-step undo/redo. Known limit: switching to a style with a different element count is rejected until the script also clears the vertices' `segments`; only the rejection is covered by the integration test. | | Dimension | **Complete** (all 9 subtypes) | `EntityType::Dimension` wraps a nine-variant enum, so the adapter uses a hand-written flat model (`dimension_model.rs.inc`, wired in through the manifest's `manual_kinds`) and the coverage catalog lists its properties through the policy's `synthetic` section. Python sees `subtype` (fixed at creation), the shared base fields (`text`, `style_name`, `normal`, `text_middle_point`, `attachment_point`, `text_rotation`, `horizontal_direction`, `flip_arrow1/2`, `text_user_positioned`) and only the fields of that subtype; a field from another subtype is rejected, not ignored. `actual_measurement` and the base definition point are host-derived (`normalize_scripted_dimension`, the same recompute grip edits use). Unmapped but preserved: `user_text`, `insertion_point`, `block_name`, version and DWG flags, style overrides in XDATA. Per-subtype status, all verified by `staged_python_dimension_lifecycle_over_real_ipc` (C/R/E/D/U/I/W/V/P; measurement and canvas wires checked for each, DWG and DXF reopen recorded per subtype): Linear (10), Aligned (5), Radius (5), Diameter (10), Angular3Pt (90°), Angular2Ln (90°), Ordinate (5), Arc (5·π/2), LargeRadial (6) — every one creates, reads, derives the right measurement, renders wires, and keeps its measurement through DWG and DXF. Edits (Linear second point/text/attachment, Radius definition point) update the measurement, nine rejection cases plus a cross-entity atomic rollback and three create-time rejections leave the document untouched, and post-reopen re-edit passes in both formats. Not covered: text formatting overrides, arrowheads and other DIMSTYLE overrides stored in XDATA, the anonymous `*D` block. The subtype-specific field meanings for the angular subtypes follow the DXF group-code layout rather than the acadrust convenience constructors. | | MultiLeader | **Complete** | Mapped through the generic generator, which gained support for `Color`, `LineWeight`, mixed unit/payload enums, fixed `[f64; N]` arrays, bitflags-2 newtypes and per-type module paths for this kind. Writable: `content_type`, `path_type`, `line_color`, `line_weight`, `enable_landing`, `enable_dogleg`, `dogleg_length`, `arrowhead_size`, `text_color`, `text_frame`, the four text attachments, attachment direction/point, `text_alignment`, `text_angle_type`, `text_direction_negative`, `scale_factor`, `enable_annotation_scale`, `extend_leader_to_text`, block content fields, the reference handles (`style_handle`, `text_style_handle`, `arrowhead_handle`, `line_type_handle`, `block_content_handle`) and `context`, which merges as a patch so a script can change `text_string` without resetting leader roots. Read-only: `dwg_version`, `property_override_flags`, `text_align_in_ipe` and the entity-level `text_height` (neither writer stores it; set `context.text_height`). Excluded but preserved: `arrowhead_overrides`, `block_attributes`. Host validation checks finite/positive scale, height, dogleg and arrow sizes, finite context geometry and transform, non-empty leader lines, content-type/context consistency, and that every referenced handle exists as the right object kind. `staged_python_multileader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: MText creation with a three-point leader root, a partial-context edit that replaces the path (four points), text, text location, dogleg and colour while preserving scale/content/common fields, canvas wires, eleven rejection cases plus a duplicate-handle atomic rollback, DWG and DXF reopen (text, path, dogleg, `context.text_height`, colour) with post-reopen re-edit, and three-step undo/redo. Not covered: block-content MultiLeaders (no block fixture yet), tolerance content, and style-defined defaults. | -| Table | Layer-only | Requires table style and cell fixture. | +| Table | **Complete** | Mapped through the generic generator (now also handling tuples and `usize`, with the generic `LegacyBorderOverrides` fields excluded and preserved). Writable: `insertion_point`, `horizontal_direction`, `normal`, `table_style_handle`, `rows`, `columns`, `merged_ranges`, `break_spacing`, `break_flow_direction`, `break_options`. Read-only: `block_name`, `block_record_handle`, `data_version`, `value_flags`, the override flags, `name`, `description`, `field_handles`, `base_style`, `break_data`, `break_ranges`, `legacy_style_override`. Creation yields the engine default 3x3 grid; scripts read `rows`/`columns`, modify or extend them and assign them back (rows and columns replace wholesale, so copy an existing row dict when adding one). Host validation requires a non-empty grid, positive finite row heights and column widths, every row to have exactly one cell per column, finite cell and content geometry, merged ranges inside the grid and non-overlapping, and existing table-style, cell text-style and cell block handles. `normalize_scripted_table` mirrors `merged_ranges` onto the origin cell's `merge_width/height` on every scripted change to the ranges. `staged_python_table_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: default-grid creation, a row append, column-width edit, a text cell, a three-column merge and break spacing in one transaction with untouched rows/columns/style/common fields preserved, canvas wires, ten rejection cases plus an atomic rollback, DWG and DXF reopen (row count, width, cell text, insertion) with re-edit, and three-step undo/redo. Format notes: DWG restores `merged_ranges` but not per-cell merge dimensions; DXF restores the cell dimensions but its reader does not rebuild `merged_ranges` (recorded by a canary assertion); both keep the merge. Not covered: the anonymous table block is not regenerated, cell styles and formats beyond text, linked data and field content. | | PolygonMesh | Layer-only | Requires consistent grid/count fixture. | | PolyfaceMesh | Layer-only | Requires valid face-index fixture. | | Mesh | Layer-only | Requires valid topology fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index af44b53b4..6b04eb987 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 26 geometry converters. Other writable properties +canvas kinds outside its 27 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 26-kind generated schema it contains only handle, kind, and layer; +outside the 27-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 3ea5ffde3..d525eac61 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 26 mapped kinds. +the 27 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index df42470e9..f225ab043 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -201,6 +201,10 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec { } while let Some(name) = queue.pop() { + if let Some(parts) = tuple_parts(&name) { + queue.extend(parts.into_iter().map(str::to_owned)); + continue; + } if BUILTIN.contains(&name.as_str()) || SPECIAL.contains(&name.as_str()) || f64_array_len(&name).is_some() @@ -300,6 +304,26 @@ fn is_native(type_id: &str) -> bool { BUILTIN.contains(&type_id) } +/// Split a tuple type id such as `(u32,CellBorder)` into its element types. +fn tuple_parts(type_id: &str) -> Option> { + let inner = type_id.strip_prefix('(')?.strip_suffix(')')?; + let mut parts = Vec::new(); + let (mut depth, mut start) = (0i32, 0usize); + for (i, ch) in inner.char_indices() { + match ch { + '(' | '[' | '<' => depth += 1, + ')' | ']' | '>' => depth -= 1, + ',' if depth == 0 => { + parts.push(inner[start..i].trim()); + start = i + 1; + } + _ => {} + } + } + parts.push(inner[start..].trim()); + (parts.len() >= 2).then_some(parts) +} + /// `[f64; N]` fixed arrays (e.g. a 4x4 transform) convert as flat float lists. fn f64_array_len(type_id: &str) -> Option { type_id @@ -313,6 +337,10 @@ fn default_expr(type_id: &str) -> String { if let Some(n) = f64_array_len(type_id) { return format!("[0.0f64; {n}]"); } + if let Some(parts) = tuple_parts(type_id) { + let items: Vec = parts.iter().map(|part| default_expr(part)).collect(); + return format!("({})", items.join(", ")); + } match type_id { "f64" | "f32" => "0.0".to_string(), "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => "0".to_string(), @@ -339,6 +367,14 @@ fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String "Ok(vm.ctx.new_list(({item_expr}).iter().map(|item| vm.new_pyobj(*item)).collect()).into())" ); } + if let Some(parts) = tuple_parts(type_id) { + let items: Vec = parts + .iter() + .enumerate() + .map(|(i, part)| format!("{}?", leaf_to_py(registry, part, &format!("&({item_expr}).{i}")))) + .collect(); + return format!("Ok(vm.ctx.new_list(vec![{}]).into())", items.join(", ")); + } match type_id { "f64" | "f32" | "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "bool" => { format!("Ok(vm.new_pyobj(*{parenthesized}))") @@ -365,12 +401,28 @@ fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String /// `value_expr` must already be an owned `PyObjectRef` expression. Result: a `PyResult` expression. fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> String { + if type_id == "u64" { + // serde reports Rust `usize` as `u64`; the inferred cast serves both. + return format!("{value_expr}.try_into_value::(vm).map(|n| n as _)"); + } if is_native(type_id) { return format!("{value_expr}.try_into_value::<{}>(vm)", native_rust_type(type_id)); } if let Some(n) = f64_array_len(type_id) { return format!("py_to_f64_array::<{n}>({value_expr}, vm)"); } + if let Some(parts) = tuple_parts(type_id) { + let n = parts.len(); + let items: Vec = parts + .iter() + .enumerate() + .map(|(i, part)| format!("{}?", leaf_from_py(registry, part, &format!("items.remove(0 * {i})")))) + .collect(); + return format!( + "(|| -> PyResult<_> {{ let mut items: Vec = ({value_expr}).try_into_value(vm)?; if items.len() != {n} {{ return Err(vm.new_value_error(\"expected a {n}-item list\".to_owned())); }} Ok(({})) }})()", + items.join(", ") + ); + } match type_id { "Vector3" => format!("py_to_vector3_dict({value_expr}, vm)"), "Vector2" => format!("py_to_vector2({value_expr}, vm)"), diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index d9377cf0f..55f000e6f 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -25,7 +25,8 @@ "Leader", "MLine", "Dimension", - "MultiLeader" + "MultiLeader", + "Table" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -280,6 +281,35 @@ "rust_setter": "if let Some(v) = dict.get_item_opt(\"context\", vm)? { VAR.context = merge_multileader_context(&VAR.context, v, vm)?; }" } } + }, + "Table": { + "fields": { + "insertion_point": { "required": true }, + "block_name": { "read_only": true }, + "block_record_handle": { "read_only": true }, + "data_version": { "read_only": true }, + "value_flags": { "read_only": true }, + "override_flag": { "read_only": true }, + "override_border_color": { "read_only": true }, + "override_border_line_weight": { "read_only": true }, + "override_border_visibility": { "read_only": true }, + "legacy_style_override": { "read_only": true }, + "name": { "read_only": true }, + "description": { "read_only": true }, + "field_handles": { "read_only": true }, + "base_style": { "read_only": true }, + "break_data": { "read_only": true }, + "break_ranges": { "read_only": true }, + "dwg_unknown_byte": { "exclude": true }, + "dwg_unknown_handle": { "exclude": true }, + "dwg_unknown_long1": { "exclude": true }, + "dwg_r2010_unknown_bit": { "exclude": true }, + "dwg_unknown_long2": { "exclude": true }, + "dwg_unknown_short": { "exclude": true }, + "legacy_border_colors": { "exclude": true, "_comment": "Generic LegacyBorderOverrides is not expressible by the generator; legacy R13-R2000 border overrides are preserved untouched." }, + "legacy_border_line_weights": { "exclude": true }, + "legacy_border_visibility": { "exclude": true } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index f706c947a..13228f283 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4756,7 +4756,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 26); + assert_eq!(emitted.len(), 27); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 19a0a3aea..15cc72212 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -158,6 +158,13 @@ impl<'a> HostSession<'a> { if let EntityType::Dimension(dimension) = entity { crate::entities::dimension::normalize_scripted_dimension(dimension); } + if let EntityType::Table(new) = entity { + let old = match old { + Some(EntityType::Table(old)) => Some(old), + _ => None, + }; + crate::entities::table::normalize_scripted_table(old, new); + } if let EntityType::MLine(new) = entity { let old = match old { Some(EntityType::MLine(old)) => Some(old), @@ -2937,6 +2944,162 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } + #[test] + fn staged_python_table_lifecycle_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + + dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Table', insertion_point={'x':5.0,'y':5.0,'z':0.0}).handle"); + let handle = host.document().entities().find_map(|entity| match entity { + EntityType::Table(value) => Some(value.common.handle), + _ => None, + }).unwrap_or_else(|| panic!("Python did not create Table: {}", last_output(&host))); + let created = host.document().get_entity(handle).unwrap().clone(); + let EntityType::Table(created_table) = &created else { unreachable!() }; + assert_eq!((created_table.rows.len(), created_table.columns.len()), (3, 3)); + assert_eq!(created_table.insertion_point, acadrust::types::Vector3::new(5.0, 5.0, 0.0)); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); + + let script = std::env::temp_dir().join(format!("ocs_table_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "t = doc.entities[{}]\n", + "rows = t.rows\n", + "cols = t.columns\n", + "rows.append(dict(rows[0]))\n", + "cols[0]['width'] = 30.0\n", + "rows[1]['cells'][1]['contents'] = [{{'content_type': 'Value', 'scale': 1.0, 'text_height': 2.5,\n", + " 'value': {{'value_type': 'String', 'raw_type_code': 4, 'text': 'Hello', 'formatted_value': 'Hello'}}}}]\n", + "with doc.transaction('Edit table'):\n", + " t.rows = rows\n", + " t.columns = cols\n", + " t.merged_ranges = [{{'top_row': 0, 'left_col': 0, 'bottom_row': 0, 'right_col': 2}}]\n", + " t.break_spacing = 2.0\n", + "doc.selection = [t]\n" + ), handle.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let expected = host.document().get_entity(handle).unwrap().clone(); + let EntityType::Table(edited) = &expected else { unreachable!() }; + assert_eq!(edited.rows.len(), 4, "edit failed: {}", last_output(&host)); + assert_eq!(edited.columns[0].width, 30.0); + assert_eq!(edited.rows[1].cells[1].contents[0].value.text, "Hello"); + assert_eq!(edited.merged_ranges.len(), 1); + assert_eq!(edited.break_spacing, 2.0); + assert_eq!(edited.common, created_table.common); + assert_eq!(edited.insertion_point, created_table.insertion_point); + assert_eq!(edited.normal, created_table.normal); + assert_eq!(edited.columns[1], created_table.columns[1]); + assert_eq!(edited.rows[2], created_table.rows[2]); + assert_eq!(edited.table_style_handle, created_table.table_style_handle); + assert_eq!(host.selection(), vec![handle]); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty()); + + for (patch, message) in [ + ("'columns':[{'name':'A','width':0.0},{'width':1.0},{'width':1.0}]", "greater than zero"), + ("'columns':[{'width':5.0}]", "cells but the table has 1 columns"), + ("'columns':[]", "at least one row and one column"), + ("'merged_ranges':[{'top_row':0,'left_col':0,'bottom_row':9,'right_col':0}]", "outside the"), + ("'merged_ranges':[{'top_row':1,'left_col':0,'bottom_row':2,'right_col':1},{'top_row':2,'left_col':1,'bottom_row':3,'right_col':2}]", "overlaps"), + ("'table_style_handle':999999", "does not exist"), + ("'block_name':'x'", "read-only"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'break_spacing':-1.0", "non-negative"), + ("'insertion_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ] { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject table', [{{'handle':{}, {patch}}}])", handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + dispatch(&mut host, &format!(concat!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{},'break_spacing':7.0}},", + "{{'handle':{},'break_spacing':-1.0}}])" + ), handle.value(), handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + + let mut gaps = Vec::new(); + let dwg = crate::io::load_bytes("table.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("table.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + let Some(EntityType::Table(value)) = document.get_entity(handle) else { + gaps.push(format!("{format}: Table missing")); + continue; + }; + if value.rows.len() != 4 { gaps.push(format!("{format}: rows {}", value.rows.len())); } + if value.columns.first().map(|c| c.width) != Some(30.0) { gaps.push(format!("{format}: column width {:?}", value.columns.first().map(|c| c.width))); } + let text = value.rows.get(1).and_then(|r| r.cells.get(1)).and_then(|c| c.contents.first()).map(|c| c.value.text.clone()); + if text.as_deref() != Some("Hello") { gaps.push(format!("{format}: cell text {text:?}")); } + // DWG stores the ranges; DXF stores the origin cell's merge + // dimensions and its reader does not rebuild `merged_ranges` + // (engine gap, recorded in the ledger). Scripted saves populate + // both, so each format keeps the merge in its own form. + let origin = &value.rows[0].cells[0]; + if format == "DXF" { + if (origin.merge_width, origin.merge_height) != (3, 1) { gaps.push(format!("DXF: merge dims {}x{}", origin.merge_width, origin.merge_height)); } + if !value.merged_ranges.is_empty() { gaps.push("DXF: reader now rebuilds merged_ranges; drop the blocker".into()); } + } else if value.merged_ranges.len() != 1 { + gaps.push(format!("DWG: merged_ranges {}", value.merged_ranges.len())); + } + if value.insertion_point != edited.insertion_point { gaps.push(format!("{format}: insertion {:?}", value.insertion_point)); } + } + assert!(gaps.is_empty(), "persistence gaps: {gaps:#?}"); + for (name, mut document) in [("table-reedit.dwg", dwg), ("table-reedit.dxf", dxf)] { + let before = document.get_entity(handle).unwrap().clone(); + let mut after = before.clone(); + let EntityType::Table(value) = &mut after else { unreachable!() }; + value.columns[1].width = 12.0; + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + *document.get_entity_mut(handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + assert!(matches!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle), + Some(EntityType::Table(value)) if value.columns[1].width == 12.0), "{name}"); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + for (name, bytes) in [ + ("table-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("table-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { diff --git a/src/entities/table.rs b/src/entities/table.rs index 8039b4615..f2de572b8 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -3339,3 +3339,29 @@ impl Transformable for Table { }); } } + +/// Keep the per-cell merge dimensions (what DXF stores) in step with +/// `merged_ranges` (what DWG stores) after a scripted create or edit. Cells a +/// script did not touch keep their merge state when the ranges are unchanged. +pub(crate) fn normalize_scripted_table(old: Option<&Table>, new: &mut Table) { + if old.is_some_and(|old| old.merged_ranges == new.merged_ranges) { + return; + } + for row in &mut new.rows { + for cell in &mut row.cells { + cell.merge_width = 1; + cell.merge_height = 1; + } + } + let ranges = new.merged_ranges.clone(); + for range in &ranges { + if let Some(cell) = new + .rows + .get_mut(range.top_row) + .and_then(|row| row.cells.get_mut(range.left_col)) + { + cell.merge_width = range.col_count() as i32; + cell.merge_height = range.row_count() as i32; + } + } +} From b97d74efa48d2db034247dc0bb38785ee70ce2ab Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 14:42:42 +0200 Subject: [PATCH 23/76] Complete PolygonMesh, PolyfaceMesh and Mesh host model coverage Maps all three mesh kinds with host validation of grid counts, face indices and edge topology, and skips sub-record common data in the generator. A shared run_mesh_lifecycle driver passes the full gate for each kind, including DWG and DXF reopen with re-edit. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 3 + .../entity_coverage_policy.json | 39 +++ crates/ocs_plugin_api/src/entity_coverage.rs | 154 ++++++++++++ docs/plugin-host-model-completion-plan.md | 11 +- docs/plugin-host-model-coverage-ledger.md | 8 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/build/generate.rs | 12 +- plugins/opencad-python/entity_manifest.json | 23 +- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 236 ++++++++++++++++++ 11 files changed, 479 insertions(+), 15 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 3af908d70..4d0e3ae57 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -787,6 +787,9 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { ) | ("MultiLeader", _) | ("Table", _) + | ("PolygonMesh", _) + | ("PolyfaceMesh", _) + | ("Mesh", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 145a7197e..fc277f86b 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -344,6 +344,39 @@ "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" ] }, "readable": { @@ -393,6 +426,12 @@ "base_style", "break_data", "break_ranges" + ], + "PolyfaceMesh": [ + "seqend_handle" + ], + "Mesh": [ + "version" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index f5debad44..1e56b8b75 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -622,6 +622,127 @@ fn validate_table( Ok(()) } +/// PolygonMesh: an M x N grid; the vertex list must match exactly. +#[cfg(feature = "host")] +fn validate_polygon_mesh( + old: Option<&crate::host::acadrust::entities::PolygonMeshEntity>, + new: &crate::host::acadrust::entities::PolygonMeshEntity, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if new.m_vertex_count < 2 || new.n_vertex_count < 2 { + return Err("PolygonMesh.m_vertex_count and n_vertex_count must be at least 2".into()); + } + let expected = new.m_vertex_count as usize * new.n_vertex_count as usize; + if new.vertices.len() != expected { + return Err(format!( + "PolygonMesh has {} vertices but {}x{} requires {expected}", + new.vertices.len(), + new.m_vertex_count, + new.n_vertex_count + )); + } + if new.m_smooth_density < 0 || new.n_smooth_density < 0 { + return Err("PolygonMesh smooth densities must be non-negative".into()); + } + if !new.elevation.is_finite() { + return Err("PolygonMesh.elevation must be finite".into()); + } + solid_normal("PolygonMesh.normal", &new.normal)?; + for (index, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("PolygonMesh.vertices[{index}].location"), &vertex.location)?; + } + Ok(()) +} + +/// PolyfaceMesh: faces index the vertex list (1-based; a negative index hides +/// that edge; 0 marks an unused corner). +#[cfg(feature = "host")] +fn validate_polyface_mesh( + old: Option<&crate::host::acadrust::entities::PolyfaceMesh>, + new: &crate::host::acadrust::entities::PolyfaceMesh, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if !new.elevation.is_finite() || !new.thickness.is_finite() || !new.start_width.is_finite() || !new.end_width.is_finite() { + return Err("PolyfaceMesh elevation, thickness and widths must be finite".into()); + } + solid_normal("PolyfaceMesh.normal", &new.normal)?; + if new.vertices.len() < 3 { + return Err("PolyfaceMesh.vertices requires at least 3 vertices".into()); + } + for (index, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("PolyfaceMesh.vertices[{index}].location"), &vertex.location)?; + if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() || !vertex.curve_tangent.is_finite() { + return Err(format!("PolyfaceMesh.vertices[{index}] has non-finite bulge, width or tangent")); + } + } + if new.faces.is_empty() { + return Err("PolyfaceMesh.faces requires at least 1 face".into()); + } + let count = new.vertices.len() as i64; + for (index, face) in new.faces.iter().enumerate() { + let indices = [face.index1, face.index2, face.index3, face.index4]; + for value in indices { + if i64::from(value).abs() > count { + return Err(format!( + "PolyfaceMesh.faces[{index}] references vertex {} but only {count} exist", + value.abs() + )); + } + } + if indices.iter().filter(|value| **value != 0).count() < 3 { + return Err(format!("PolyfaceMesh.faces[{index}] needs at least 3 vertex indices")); + } + } + Ok(()) +} + +/// Mesh (subdivision): faces and edges index the vertex list (0-based). +#[cfg(feature = "host")] +fn validate_mesh( + old: Option<&crate::host::acadrust::entities::Mesh>, + new: &crate::host::acadrust::entities::Mesh, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if new.subdivision_level < 0 { + return Err("Mesh.subdivision_level must be non-negative".into()); + } + if new.vertices.len() < 3 { + return Err("Mesh.vertices requires at least 3 vertices".into()); + } + for (index, vertex) in new.vertices.iter().enumerate() { + finite_vector(&format!("Mesh.vertices[{index}]"), vertex)?; + } + for (index, face) in new.faces.iter().enumerate() { + if face.vertices.len() < 3 { + return Err(format!("Mesh.faces[{index}] needs at least 3 vertices")); + } + if let Some(bad) = face.vertices.iter().find(|vertex| **vertex >= new.vertices.len()) { + return Err(format!( + "Mesh.faces[{index}] references vertex {bad} but only {} exist", + new.vertices.len() + )); + } + } + for (index, edge) in new.edges.iter().enumerate() { + if edge.start >= new.vertices.len() || edge.end >= new.vertices.len() { + return Err(format!("Mesh.edges[{index}] references a vertex out of range")); + } + if edge.start == edge.end { + return Err(format!("Mesh.edges[{index}] has identical endpoints")); + } + if edge.crease.is_some_and(|crease| !crease.is_finite() || crease < 0.0) { + return Err(format!("Mesh.edges[{index}].crease must be finite and non-negative")); + } + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -718,6 +839,9 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::Dimension(value) => validate_dimension(None, value)?, EntityType::MultiLeader(value) => validate_multileader(None, value)?, EntityType::Table(value) => validate_table(None, value)?, + EntityType::PolygonMesh(value) => validate_polygon_mesh(None, value)?, + EntityType::PolyfaceMesh(value) => validate_polyface_mesh(None, value)?, + EntityType::Mesh(value) => validate_mesh(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1075,6 +1199,9 @@ pub fn validate_entity_mutation( (EntityType::Dimension(old), EntityType::Dimension(new)) => validate_dimension(Some(old), new)?, (EntityType::MultiLeader(old), EntityType::MultiLeader(new)) => validate_multileader(Some(old), new)?, (EntityType::Table(old), EntityType::Table(new)) => validate_table(Some(old), new)?, + (EntityType::PolygonMesh(old), EntityType::PolygonMesh(new)) => validate_polygon_mesh(Some(old), new)?, + (EntityType::PolyfaceMesh(old), EntityType::PolyfaceMesh(new)) => validate_polyface_mesh(Some(old), new)?, + (EntityType::Mesh(old), EntityType::Mesh(new)) => validate_mesh(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -1749,6 +1876,9 @@ mod tests { | "Dimension" | "MultiLeader" | "Table" + | "PolygonMesh" + | "PolyfaceMesh" + | "Mesh" ) { assert!( @@ -1980,6 +2110,30 @@ mod tests { "Table.break_spacing".to_owned(), "Table.break_flow_direction".to_owned(), "Table.break_options".to_owned(), + "PolygonMesh.flags".to_owned(), + "PolygonMesh.m_vertex_count".to_owned(), + "PolygonMesh.n_vertex_count".to_owned(), + "PolygonMesh.m_smooth_density".to_owned(), + "PolygonMesh.n_smooth_density".to_owned(), + "PolygonMesh.smooth_type".to_owned(), + "PolygonMesh.elevation".to_owned(), + "PolygonMesh.normal".to_owned(), + "PolygonMesh.vertices".to_owned(), + "PolyfaceMesh.elevation".to_owned(), + "PolyfaceMesh.flags".to_owned(), + "PolyfaceMesh.normal".to_owned(), + "PolyfaceMesh.start_width".to_owned(), + "PolyfaceMesh.end_width".to_owned(), + "PolyfaceMesh.smooth_surface".to_owned(), + "PolyfaceMesh.thickness".to_owned(), + "PolyfaceMesh.vertices".to_owned(), + "PolyfaceMesh.faces".to_owned(), + "Mesh.blend_crease".to_owned(), + "Mesh.subdivision_level".to_owned(), + "Mesh.vertices".to_owned(), + "Mesh.faces".to_owned(), + "Mesh.edges".to_owned(), + "Mesh.override_option".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 49033db44..8fd05c77a 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **27 of 43** canvas kinds: 26 -are creatable and `Insert` remains update-only. This is **not** 27 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader and Table pass every completion gate. +v7. The Python feature has explicit writes for **30 of 43** canvas kinds: 29 +are creatable and `Insert` remains update-only. This is **not** 30 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh and Mesh pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 16 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 13 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **PolygonMesh** (queue item 6). Leader is done except for its W +Continue with **Helix** (queue item 9). Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -162,3 +162,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `Dimension` for all nine subtypes. Because the payload is an enum, the adapter generator gained a `manual_kinds` hook (hand-written `_to_dict/_from_dict/_apply`) and the host catalog a `synthetic` policy section. The host derives `actual_measurement` and the base definition point on every scripted create/edit. Python ints are now accepted wherever a coordinate or angle is expected. `staged_python_dimension_lifecycle_over_real_ipc` records each subtype separately and all nine pass C/R/E/D/U/I/W/V/P. The queue now continues with `MultiLeader`. - Completed `MultiLeader`. The adapter generator now converts `Color`, `LineWeight`, mixed unit/payload enums, fixed float arrays and bitflags-2 newtypes, and a `keep_types` override lets a hand-written setter reuse generated converters (used for the merge-style `context` patch). `Leader.override_color` became writable through the same `Color` form; neither file format stores it. `staged_python_multileader_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `Table`. - Completed `Table`. The generator now handles tuples and `usize`; the host keeps DXF cell merge dimensions in step with `merged_ranges`. `staged_python_table_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `PolygonMesh`. +- Completed `PolygonMesh`, `PolyfaceMesh` and `Mesh` (queue items 6-8) with one shared `run_mesh_lifecycle` test driver and per-kind host validation. The generator now omits a sub-record's own `EntityCommon` from the scripted form. All three pass the full gate including DWG and DXF reopen with re-edit. The queue now continues with `Helix`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index a89a4eb88..1f1d2cb1c 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **27 of 43 canvas kinds have explicit geometry writes** (26 creatable, plus update-only `Insert`), **8 kinds are complete**, and 16 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **30 of 43 canvas kinds have explicit geometry writes** (29 creatable, plus update-only `Insert`), **11 kinds are complete**, and 13 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -33,9 +33,9 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Dimension | **Complete** (all 9 subtypes) | `EntityType::Dimension` wraps a nine-variant enum, so the adapter uses a hand-written flat model (`dimension_model.rs.inc`, wired in through the manifest's `manual_kinds`) and the coverage catalog lists its properties through the policy's `synthetic` section. Python sees `subtype` (fixed at creation), the shared base fields (`text`, `style_name`, `normal`, `text_middle_point`, `attachment_point`, `text_rotation`, `horizontal_direction`, `flip_arrow1/2`, `text_user_positioned`) and only the fields of that subtype; a field from another subtype is rejected, not ignored. `actual_measurement` and the base definition point are host-derived (`normalize_scripted_dimension`, the same recompute grip edits use). Unmapped but preserved: `user_text`, `insertion_point`, `block_name`, version and DWG flags, style overrides in XDATA. Per-subtype status, all verified by `staged_python_dimension_lifecycle_over_real_ipc` (C/R/E/D/U/I/W/V/P; measurement and canvas wires checked for each, DWG and DXF reopen recorded per subtype): Linear (10), Aligned (5), Radius (5), Diameter (10), Angular3Pt (90°), Angular2Ln (90°), Ordinate (5), Arc (5·π/2), LargeRadial (6) — every one creates, reads, derives the right measurement, renders wires, and keeps its measurement through DWG and DXF. Edits (Linear second point/text/attachment, Radius definition point) update the measurement, nine rejection cases plus a cross-entity atomic rollback and three create-time rejections leave the document untouched, and post-reopen re-edit passes in both formats. Not covered: text formatting overrides, arrowheads and other DIMSTYLE overrides stored in XDATA, the anonymous `*D` block. The subtype-specific field meanings for the angular subtypes follow the DXF group-code layout rather than the acadrust convenience constructors. | | MultiLeader | **Complete** | Mapped through the generic generator, which gained support for `Color`, `LineWeight`, mixed unit/payload enums, fixed `[f64; N]` arrays, bitflags-2 newtypes and per-type module paths for this kind. Writable: `content_type`, `path_type`, `line_color`, `line_weight`, `enable_landing`, `enable_dogleg`, `dogleg_length`, `arrowhead_size`, `text_color`, `text_frame`, the four text attachments, attachment direction/point, `text_alignment`, `text_angle_type`, `text_direction_negative`, `scale_factor`, `enable_annotation_scale`, `extend_leader_to_text`, block content fields, the reference handles (`style_handle`, `text_style_handle`, `arrowhead_handle`, `line_type_handle`, `block_content_handle`) and `context`, which merges as a patch so a script can change `text_string` without resetting leader roots. Read-only: `dwg_version`, `property_override_flags`, `text_align_in_ipe` and the entity-level `text_height` (neither writer stores it; set `context.text_height`). Excluded but preserved: `arrowhead_overrides`, `block_attributes`. Host validation checks finite/positive scale, height, dogleg and arrow sizes, finite context geometry and transform, non-empty leader lines, content-type/context consistency, and that every referenced handle exists as the right object kind. `staged_python_multileader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: MText creation with a three-point leader root, a partial-context edit that replaces the path (four points), text, text location, dogleg and colour while preserving scale/content/common fields, canvas wires, eleven rejection cases plus a duplicate-handle atomic rollback, DWG and DXF reopen (text, path, dogleg, `context.text_height`, colour) with post-reopen re-edit, and three-step undo/redo. Not covered: block-content MultiLeaders (no block fixture yet), tolerance content, and style-defined defaults. | | Table | **Complete** | Mapped through the generic generator (now also handling tuples and `usize`, with the generic `LegacyBorderOverrides` fields excluded and preserved). Writable: `insertion_point`, `horizontal_direction`, `normal`, `table_style_handle`, `rows`, `columns`, `merged_ranges`, `break_spacing`, `break_flow_direction`, `break_options`. Read-only: `block_name`, `block_record_handle`, `data_version`, `value_flags`, the override flags, `name`, `description`, `field_handles`, `base_style`, `break_data`, `break_ranges`, `legacy_style_override`. Creation yields the engine default 3x3 grid; scripts read `rows`/`columns`, modify or extend them and assign them back (rows and columns replace wholesale, so copy an existing row dict when adding one). Host validation requires a non-empty grid, positive finite row heights and column widths, every row to have exactly one cell per column, finite cell and content geometry, merged ranges inside the grid and non-overlapping, and existing table-style, cell text-style and cell block handles. `normalize_scripted_table` mirrors `merged_ranges` onto the origin cell's `merge_width/height` on every scripted change to the ranges. `staged_python_table_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: default-grid creation, a row append, column-width edit, a text cell, a three-column merge and break spacing in one transaction with untouched rows/columns/style/common fields preserved, canvas wires, ten rejection cases plus an atomic rollback, DWG and DXF reopen (row count, width, cell text, insertion) with re-edit, and three-step undo/redo. Format notes: DWG restores `merged_ranges` but not per-cell merge dimensions; DXF restores the cell dimensions but its reader does not rebuild `merged_ranges` (recorded by a canary assertion); both keep the merge. Not covered: the anonymous table block is not regenerated, cell styles and formats beyond text, linked data and field content. | -| PolygonMesh | Layer-only | Requires consistent grid/count fixture. | -| PolyfaceMesh | Layer-only | Requires valid face-index fixture. | -| Mesh | Layer-only | Requires valid topology fixture. | +| PolygonMesh | **Complete** | Writable: `flags`, `m_vertex_count`, `n_vertex_count`, `m_smooth_density`, `n_smooth_density`, `smooth_type`, `elevation`, `normal`, `vertices` (each `{location, flags}`; a vertex's own common data is not scripted and defaults). Host validation requires M and N of at least 2, exactly M×N vertices, non-negative densities, finite elevation and locations, and a nonzero normal, so inconsistent counts are refused before any mutation. `staged_python_polygon_mesh_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: a 3×3 grid, a vertex move plus density and cubic smoothing in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | +| PolyfaceMesh | **Complete** | Writable: `elevation`, `flags`, `normal`, `start_width`, `end_width`, `smooth_surface`, `thickness`, `vertices` (location, flags, bulge, widths, tangent, id) and `faces` (four indices, flags, optional colour). `seqend_handle` is read-only and preserved. Host validation requires at least 3 finite vertices and 1 face, every face index within the vertex count (a negative index hides that edge, 0 marks an unused corner), at least 3 indices per face, and finite widths, thickness and elevation. `staged_python_polyface_mesh_lifecycle_over_real_ipc` passes the full gate: two triangles, a vertex move plus a new vertex and a hidden-edge face in one transaction, canvas wires, six rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | +| Mesh | **Complete** | Writable: `blend_crease`, `subdivision_level`, `vertices`, `faces`, `edges` (start, end, optional crease), `override_option`; `version` is read-only. Host validation requires at least 3 finite vertices, faces of at least 3 in-range indices, edges with distinct in-range endpoints and a finite non-negative crease, and a non-negative subdivision level, so out-of-range topology is rejected before mutation. `staged_python_mesh_lifecycle_over_real_ipc` passes the full gate: a quad with a creased edge, an added vertex and triangle plus subdivision level 1 in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Helix | Layer-only | Requires valid derived-spline fixture. | | RasterImage | Layer-only | Requires image definition and file fixture. | | Wipeout | Layer-only | Requires clip and image-definition fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 6b04eb987..e6c25d849 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 27 geometry converters. Other writable properties +canvas kinds outside its 30 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 27-kind generated schema it contains only handle, kind, and layer; +outside the 30-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index d525eac61..214d16b05 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 27 mapped kinds. +the 30 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index f225ab043..219747fee 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -218,7 +218,9 @@ fn helper_closure(manifest: &Manifest, registry: &TypeRegistry) -> Vec { panic!("entity_manifest.json: type {name} (reached transitively) is not in the type registry"); }; for f in &info.fields { - queue.push(f.type_id.as_str().to_string()); + if f.type_id.as_str() != "EntityCommon" { + queue.push(f.type_id.as_str().to_string()); + } } for v in &info.variants { for f in &v.fields { @@ -666,6 +668,14 @@ fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String { let mut from_dict_fields = String::new(); let mut default_fields = String::new(); for f in &info.fields { + if f.type_id.as_str() == "EntityCommon" { + // A sub-record's own common data (layer, XDATA, handles) is not part + // of the scripted model; it defaults on the way in. + let default = "acadrust::entities::EntityCommon::default()"; + from_dict_fields.push_str(&format!(" {}: {default},\n", f.name)); + default_fields.push_str(&format!(" {}: {default},\n", f.name)); + continue; + } let getter = getter_for_field( registry, f.type_id.as_str(), diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 55f000e6f..011b31133 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -26,7 +26,10 @@ "MLine", "Dimension", "MultiLeader", - "Table" + "Table", + "PolygonMesh", + "PolyfaceMesh", + "Mesh" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -310,6 +313,24 @@ "legacy_border_line_weights": { "exclude": true }, "legacy_border_visibility": { "exclude": true } } + }, + "PolygonMesh": { + "rust_constructor": "acadrust::entities::PolygonMeshEntity::default()", + "fields": { "vertices": { "required": true } } + }, + "PolyfaceMesh": { + "fields": { + "vertices": { "required": true }, + "faces": { "required": true }, + "seqend_handle": { "read_only": true } + } + }, + "Mesh": { + "fields": { + "vertices": { "required": true }, + "faces": { "required": true }, + "version": { "read_only": true } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 13228f283..26bd318bf 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4756,7 +4756,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 27); + assert_eq!(emitted.len(), 30); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 15cc72212..9a13acd6b 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3100,6 +3100,242 @@ mod tests { assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } + /// One scripted lifecycle shared by the mesh kinds: create, edit, + /// reject, save/reopen both formats, re-edit, delete, undo/redo. + struct MeshCase { + create: &'static str, + edit: &'static str, + rejects: &'static [(&'static str, &'static str)], + is_kind: fn(&EntityType) -> bool, + digest: fn(&EntityType) -> String, + reedit: fn(&mut EntityType), + expect_created: &'static str, + expect_edited: &'static str, + expect_reedited: &'static str, + } + + fn run_mesh_lifecycle(case: &MeshCase) { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let runner_path = std::env::var_os("OCS_PLUGIN_RUNNER_EXE") + .expect("set OCS_PLUGIN_RUNNER_EXE to the built OpenCADStudio executable"); + assert!(std::path::Path::new(&runner_path).is_file()); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), + &mut host, + crate::plugin::v4_support::notification_handler(), + ) + .unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last_output = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let run_script = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let path = std::env::temp_dir().join(format!("ocs_mesh_{tag}_{}.py", std::process::id())); + std::fs::write(&path, format!("def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\ndoc = ocs.active_document\n{body}")).unwrap(); + dispatch(host, &format!("PY_RUN {}", path.display())); + let _ = std::fs::remove_file(&path); + }; + + run_script(&mut host, "create", case.create); + let handle = host.document().entities().find(|entity| (case.is_kind)(entity)) + .map(|entity| entity.common().handle) + .unwrap_or_else(|| panic!("Python did not create the mesh: {}", last_output(&host))); + let created = host.document().get_entity(handle).unwrap().clone(); + assert_eq!((case.digest)(&created), case.expect_created); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); + + run_script(&mut host, "edit", &case.edit.replace("HANDLE", &handle.value().to_string())); + let expected = host.document().get_entity(handle).unwrap().clone(); + assert_eq!((case.digest)(&expected), case.expect_edited, "edit failed: {}", last_output(&host)); + assert_eq!(expected.common(), created.common()); + assert_eq!(host.selection(), vec![handle]); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty()); + + for (patch, message) in case.rejects { + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Reject mesh', [{{'handle':{}, {patch}}}])", handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected), "{patch}"); + assert!(last_output(&host).contains(message), "{patch}: {}", last_output(&host)); + } + let (first_patch, _) = case.rejects[0]; + dispatch(&mut host, &format!( + "PY_EVAL ocs.update_many('Atomic', [{{'handle':{h}, 'layer':'Other'}}, {{'handle':{h}, {first_patch}}}])", + h = handle.value())); + assert_eq!(host.document().get_entity(handle), Some(&expected)); + + let dwg = crate::io::load_bytes("mesh.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("mesh.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + let mut gaps = Vec::new(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + match document.get_entity(handle) { + Some(entity) if (case.digest)(entity) == case.expect_edited => {} + Some(entity) => gaps.push(format!("{format}: {}", (case.digest)(entity))), + None => gaps.push(format!("{format}: entity missing")), + } + } + assert!(gaps.is_empty(), "persistence gaps (expected {}): {gaps:#?}", case.expect_edited); + for (name, mut document) in [("mesh-reedit.dwg", dwg), ("mesh-reedit.dxf", dxf)] { + let before = document.get_entity(handle).unwrap().clone(); + let mut after = before.clone(); + (case.reedit)(&mut after); + ocs_plugin_api::entity_coverage::validate_entity_mutation(&before, &after).unwrap(); + ocs_plugin_api::entity_coverage::validate_canvas_entity_references(&document, &after).unwrap(); + *document.get_entity_mut(handle).unwrap() = after; + let bytes = if name.ends_with("dwg") { + acadrust::DwgWriter::write_to_vec(&document).unwrap() + } else { + acadrust::DxfWriter::new(&document).write_to_vec().unwrap() + }; + let reopened = crate::io::load_bytes(name, bytes).unwrap(); + assert_eq!((case.digest)(reopened.get_entity(handle).unwrap()), case.expect_reedited, "{name}"); + } + + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); + assert!(host.document().get_entity(handle).is_none()); + for (name, bytes) in [ + ("mesh-deleted.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()), + ("mesh-deleted.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()), + ] { + assert!(crate::io::load_bytes(name, bytes).unwrap().get_entity(handle).is_none()); + } + drop(process); + drop(host); + app.finish_pending_history(0); + assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); + app.undo_steps(1); + assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created)); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + app.redo_steps(3); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + + #[test] + fn staged_python_polygon_mesh_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "verts = [{'location': P(i, j, 0.0)} for i in range(3) for j in range(3)]\n", + "doc.create_entity('PolygonMesh', m_vertex_count=3, n_vertex_count=3, vertices=verts)\n"), + edit: concat!( + "mesh = doc.entities[HANDLE]\n", + "verts = mesh.vertices\n", + "verts[4]['location'] = P(1.0, 1.0, 2.5)\n", + "with doc.transaction('Edit mesh'):\n", + " mesh.vertices = verts\n", + " mesh.m_smooth_density = 6\n", + " mesh.n_smooth_density = 6\n", + " mesh.smooth_type = 'Cubic'\n", + "doc.selection = [mesh]\n"), + rejects: &[ + ("'m_vertex_count':4", "requires 12"), + ("'n_vertex_count':1", "at least 2"), + ("'m_smooth_density':-1", "non-negative"), + ("'elevation':float('nan')", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}}]*9", "finite"), + ("'smooth_type':'Spline'", "unsupported"), + ], + is_kind: |entity| matches!(entity, EntityType::PolygonMesh(_)), + digest: |entity| match entity { + EntityType::PolygonMesh(m) => format!("{}x{} v{} z{} d{}/{} {:?}", m.m_vertex_count, m.n_vertex_count, + m.vertices.len(), m.vertices[4].location.z, m.m_smooth_density, m.n_smooth_density, m.smooth_type), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::PolygonMesh(m) = entity { m.vertices[0].location.z = 1.0; }, + expect_created: "3x3 v9 z0 d0/0 NoSmooth", + expect_edited: "3x3 v9 z2.5 d6/6 Cubic", + expect_reedited: "3x3 v9 z2.5 d6/6 Cubic", + }); + } + + #[test] + fn staged_python_polyface_mesh_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "verts = [{'location': P(0.0, 0.0, 0.0)}, {'location': P(4.0, 0.0, 0.0)},\n", + " {'location': P(4.0, 4.0, 0.0)}, {'location': P(0.0, 4.0, 0.0)}]\n", + "faces = [{'index1': 1, 'index2': 2, 'index3': 3}, {'index1': 1, 'index2': 3, 'index3': 4}]\n", + "doc.create_entity('PolyfaceMesh', vertices=verts, faces=faces)\n"), + edit: concat!( + "mesh = doc.entities[HANDLE]\n", + "verts = mesh.vertices\n", + "verts[2]['location'] = P(4.0, 4.0, 3.0)\n", + "verts.append({'location': P(2.0, 6.0, 0.0)})\n", + "faces = mesh.faces\n", + "faces.append({'index1': 3, 'index2': -4, 'index3': 5})\n", + "with doc.transaction('Edit mesh'):\n", + " mesh.vertices = verts\n", + " mesh.faces = faces\n", + "doc.selection = [mesh]\n"), + rejects: &[ + ("'faces':[{'index1':1,'index2':2,'index3':9}]", "references vertex 9"), + ("'faces':[{'index1':1,'index2':2}]", "at least 3 vertex indices"), + ("'faces':[]", "at least 1 face"), + ("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 3"), + ("'thickness':float('inf')", "finite"), + ("'seqend_handle':5", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::PolyfaceMesh(_)), + digest: |entity| match entity { + EntityType::PolyfaceMesh(m) => format!("v{} f{} z{} last {:?}", m.vertices.len(), m.faces.len(), + m.vertices[2].location.z, + m.faces.last().map(|f| (f.index1, f.index2, f.index3))), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::PolyfaceMesh(m) = entity { m.vertices[0].location.z = 1.0; }, + expect_created: "v4 f2 z0 last Some((1, 3, 4))", + expect_edited: "v5 f3 z3 last Some((3, -4, 5))", + expect_reedited: "v5 f3 z3 last Some((3, -4, 5))", + }); + } + + #[test] + fn staged_python_mesh_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "verts = [P(0.0, 0.0, 0.0), P(4.0, 0.0, 0.0), P(4.0, 4.0, 0.0), P(0.0, 4.0, 0.0)]\n", + "doc.create_entity('Mesh', vertices=verts, faces=[{'vertices': [0, 1, 2, 3]}],\n", + " edges=[{'start': 0, 'end': 1, 'crease': 1.5}])\n"), + edit: concat!( + "mesh = doc.entities[HANDLE]\n", + "verts = mesh.vertices\n", + "verts.append(P(2.0, 6.0, 0.0))\n", + "faces = mesh.faces\n", + "faces.append({'vertices': [3, 2, 4]})\n", + "with doc.transaction('Edit mesh'):\n", + " mesh.vertices = verts\n", + " mesh.faces = faces\n", + " mesh.subdivision_level = 1\n", + "doc.selection = [mesh]\n"), + rejects: &[ + ("'faces':[{'vertices':[0,1,9]}]", "references vertex 9"), + ("'faces':[{'vertices':[0,1]}]", "at least 3 vertices"), + ("'edges':[{'start':0,'end':9}]", "out of range"), + ("'edges':[{'start':2,'end':2}]", "identical endpoints"), + ("'edges':[{'start':0,'end':1,'crease':-1.0}]", "non-negative"), + ("'subdivision_level':-1", "non-negative"), + ("'version':3", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::Mesh(_)), + digest: |entity| match entity { + EntityType::Mesh(m) => format!("v{} f{} e{} sub{} crease {:?}", m.vertices.len(), m.faces.len(), + m.edges.len(), m.subdivision_level, m.edges.first().and_then(|e| e.crease)), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Mesh(m) = entity { m.vertices[0].z = 1.0; }, + expect_created: "v4 f1 e1 sub0 crease Some(1.5)", + expect_edited: "v5 f2 e1 sub1 crease Some(1.5)", + expect_reedited: "v5 f2 e1 sub1 crease Some(1.5)", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From d4c25209072e8eaa27a613a1037787a09311047f Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 14:48:44 +0200 Subject: [PATCH 24/76] Map Helix in the Python host model; record DXF handedness blocker The host rebuilds the derived spline from the generating parameters on scripted create/edit. staged_python_helix_lifecycle_over_real_ipc passes every gate except DXF handedness, which acadrust's reader never applies; the gap is pinned by a canary via per-format expectations in the shared mesh lifecycle driver. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 16 +++++ crates/ocs_plugin_api/src/entity_coverage.rs | 49 +++++++++++++ docs/plugin-host-model-completion-plan.md | 7 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 11 ++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 72 ++++++++++++++++++- src/entities/helix.rs | 25 +++++++ 11 files changed, 180 insertions(+), 13 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 4d0e3ae57..958375596 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -790,6 +790,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("PolygonMesh", _) | ("PolyfaceMesh", _) | ("Mesh", _) + | ("Helix", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index fc277f86b..5c5153824 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -377,6 +377,17 @@ "faces", "edges", "override_option" + ], + "Helix": [ + "layer", + "axis_base_point", + "start_point", + "axis_vector", + "radius", + "turns", + "turn_height", + "handedness", + "constraint" ] }, "readable": { @@ -432,6 +443,11 @@ ], "Mesh": [ "version" + ], + "Helix": [ + "spline", + "major_version", + "maintenance_version" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 1e56b8b75..4ea721692 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -743,6 +743,44 @@ fn validate_mesh( Ok(()) } +/// Helix generating parameters. The spline is derived by the host, so only +/// the parameters are validated. +#[cfg(feature = "host")] +fn validate_helix( + old: Option<&crate::host::acadrust::entities::Helix>, + new: &crate::host::acadrust::entities::Helix, +) -> Result<(), String> { + if old.is_some_and(|old| { + old.axis_base_point == new.axis_base_point + && old.start_point == new.start_point + && old.axis_vector == new.axis_vector + && old.radius.to_bits() == new.radius.to_bits() + && old.turns.to_bits() == new.turns.to_bits() + && old.turn_height.to_bits() == new.turn_height.to_bits() + && old.handedness == new.handedness + && old.constraint == new.constraint + }) { + return Ok(()); + } + finite_vector("Helix.axis_base_point", &new.axis_base_point)?; + finite_vector("Helix.start_point", &new.start_point)?; + solid_normal("Helix.axis_vector", &new.axis_vector)?; + for (name, value) in [("radius", new.radius), ("turns", new.turns), ("turn_height", new.turn_height)] { + if !value.is_finite() || value <= 0.0 { + return Err(format!("Helix.{name} must be finite and greater than zero")); + } + } + let axis = new.axis_vector; + let length = axis.x.hypot(axis.y).hypot(axis.z); + let delta = new.start_point - new.axis_base_point; + let along = (delta.x * axis.x + delta.y * axis.y + delta.z * axis.z) / length; + let radial = ((delta.x * delta.x + delta.y * delta.y + delta.z * delta.z) - along * along).max(0.0).sqrt(); + if radial <= 1.0e-9 { + return Err("Helix.start_point must not lie on the axis".into()); + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -842,6 +880,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::PolygonMesh(value) => validate_polygon_mesh(None, value)?, EntityType::PolyfaceMesh(value) => validate_polyface_mesh(None, value)?, EntityType::Mesh(value) => validate_mesh(None, value)?, + EntityType::Helix(value) => validate_helix(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1202,6 +1241,7 @@ pub fn validate_entity_mutation( (EntityType::PolygonMesh(old), EntityType::PolygonMesh(new)) => validate_polygon_mesh(Some(old), new)?, (EntityType::PolyfaceMesh(old), EntityType::PolyfaceMesh(new)) => validate_polyface_mesh(Some(old), new)?, (EntityType::Mesh(old), EntityType::Mesh(new)) => validate_mesh(Some(old), new)?, + (EntityType::Helix(old), EntityType::Helix(new)) => validate_helix(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -1879,6 +1919,7 @@ mod tests { | "PolygonMesh" | "PolyfaceMesh" | "Mesh" + | "Helix" ) { assert!( @@ -2134,6 +2175,14 @@ mod tests { "Mesh.faces".to_owned(), "Mesh.edges".to_owned(), "Mesh.override_option".to_owned(), + "Helix.axis_base_point".to_owned(), + "Helix.start_point".to_owned(), + "Helix.axis_vector".to_owned(), + "Helix.radius".to_owned(), + "Helix.turns".to_owned(), + "Helix.turn_height".to_owned(), + "Helix.handedness".to_owned(), + "Helix.constraint".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 8fd05c77a..128f70c8f 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **30 of 43** canvas kinds: 29 +v7. The Python feature has explicit writes for **31 of 43** canvas kinds: 30 are creatable and `Insert` remains update-only. This is **not** 30 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh and Mesh pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). The other 13 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader and Helix are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). The other 12 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **Helix** (queue item 9). Leader is done except for its W +Continue with **RasterImage** (queue item 10). Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -163,3 +163,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `MultiLeader`. The adapter generator now converts `Color`, `LineWeight`, mixed unit/payload enums, fixed float arrays and bitflags-2 newtypes, and a `keep_types` override lets a hand-written setter reuse generated converters (used for the merge-style `context` patch). `Leader.override_color` became writable through the same `Color` form; neither file format stores it. `staged_python_multileader_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `Table`. - Completed `Table`. The generator now handles tuples and `usize`; the host keeps DXF cell merge dimensions in step with `merged_ranges`. `staged_python_table_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `PolygonMesh`. - Completed `PolygonMesh`, `PolyfaceMesh` and `Mesh` (queue items 6-8) with one shared `run_mesh_lifecycle` test driver and per-kind host validation. The generator now omits a sub-record's own `EntityCommon` from the scripted form. All three pass the full gate including DWG and DXF reopen with re-edit. The queue now continues with `Helix`. +- Mapped `Helix` with a host-rebuilt spline. `run_mesh_lifecycle` gained per-format DXF expectations so an engine gap is pinned by a canary instead of hidden. All gates pass except W for `handedness` in DXF (reader ignores boolean group 290). The queue now continues with `RasterImage`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 1f1d2cb1c..326839968 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **30 of 43 canvas kinds have explicit geometry writes** (29 creatable, plus update-only `Insert`), **11 kinds are complete**, and 13 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **31 of 43 canvas kinds have explicit geometry writes** (30 creatable, plus update-only `Insert`), **11 kinds are complete**, and 12 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -36,7 +36,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | PolygonMesh | **Complete** | Writable: `flags`, `m_vertex_count`, `n_vertex_count`, `m_smooth_density`, `n_smooth_density`, `smooth_type`, `elevation`, `normal`, `vertices` (each `{location, flags}`; a vertex's own common data is not scripted and defaults). Host validation requires M and N of at least 2, exactly M×N vertices, non-negative densities, finite elevation and locations, and a nonzero normal, so inconsistent counts are refused before any mutation. `staged_python_polygon_mesh_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: a 3×3 grid, a vertex move plus density and cubic smoothing in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | PolyfaceMesh | **Complete** | Writable: `elevation`, `flags`, `normal`, `start_width`, `end_width`, `smooth_surface`, `thickness`, `vertices` (location, flags, bulge, widths, tangent, id) and `faces` (four indices, flags, optional colour). `seqend_handle` is read-only and preserved. Host validation requires at least 3 finite vertices and 1 face, every face index within the vertex count (a negative index hides that edge, 0 marks an unused corner), at least 3 indices per face, and finite widths, thickness and elevation. `staged_python_polyface_mesh_lifecycle_over_real_ipc` passes the full gate: two triangles, a vertex move plus a new vertex and a hidden-edge face in one transaction, canvas wires, six rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Mesh | **Complete** | Writable: `blend_crease`, `subdivision_level`, `vertices`, `faces`, `edges` (start, end, optional crease), `override_option`; `version` is read-only. Host validation requires at least 3 finite vertices, faces of at least 3 in-range indices, edges with distinct in-range endpoints and a finite non-negative crease, and a non-negative subdivision level, so out-of-range topology is rejected before mutation. `staged_python_mesh_lifecycle_over_real_ipc` passes the full gate: a quad with a creased edge, an added vertex and triangle plus subdivision level 1 in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | -| Helix | Layer-only | Requires valid derived-spline fixture. | +| Helix | **Integration-tested / blocked at W by acadrust (DXF handedness)** | Writable generating parameters: `axis_base_point`, `start_point`, `axis_vector`, `radius` (the top radius; the base radius is the start point's distance from the axis), `turns`, `turn_height`, `handedness`, `constraint`. Read-only: `spline`, `major_version`, `maintenance_version`. The host rebuilds the derived NURBS spline with OCS's own helix builder on every scripted create or parameter edit (`normalize_scripted_helix`), so the spline always matches the parameters. Host validation requires finite positive radius, turns and turn height, a nonzero axis and a start point off the axis. `staged_python_helix_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P: creation with a derived spline ending at z = turns × turn height, a turns and handedness edit that regenerates it, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, DXF reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary assertion):** the DXF reader parses boolean group 290 as an `i16` and never applies it, so a clockwise helix reopens counter-clockwise from DXF while DWG keeps it. Not `Complete` until the reader is fixed. | | RasterImage | Layer-only | Requires image definition and file fixture. | | Wipeout | Layer-only | Requires clip and image-definition fixture. | | Underlay | Layer-only | Requires external definition/file fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index e6c25d849..d1f6a1a02 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 30 geometry converters. Other writable properties +canvas kinds outside its 31 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 30-kind generated schema it contains only handle, kind, and layer; +outside the 31-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 214d16b05..b46227440 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 30 mapped kinds. +the 31 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 011b31133..a6f2e60dd 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -29,7 +29,8 @@ "Table", "PolygonMesh", "PolyfaceMesh", - "Mesh" + "Mesh", + "Helix" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -331,6 +332,14 @@ "faces": { "required": true }, "version": { "read_only": true } } + }, + "Helix": { + "fields": { + "start_point": { "required": true }, + "spline": { "read_only": true, "_comment": "Derived by the host from the generating parameters." }, + "major_version": { "read_only": true }, + "maintenance_version": { "read_only": true } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 26bd318bf..bb6537a92 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4756,7 +4756,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 30); + assert_eq!(emitted.len(), 31); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 9a13acd6b..949b57a1b 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -158,6 +158,13 @@ impl<'a> HostSession<'a> { if let EntityType::Dimension(dimension) = entity { crate::entities::dimension::normalize_scripted_dimension(dimension); } + if let EntityType::Helix(new) = entity { + let old = match old { + Some(EntityType::Helix(old)) => Some(old), + _ => None, + }; + crate::entities::helix::normalize_scripted_helix(old, new)?; + } if let EntityType::Table(new) = entity { let old = match old { Some(EntityType::Table(old)) => Some(old), @@ -3112,6 +3119,10 @@ mod tests { expect_created: &'static str, expect_edited: &'static str, expect_reedited: &'static str, + /// Digest a DXF reopen yields when the engine loses a field (empty: + /// same as DWG). A canary that fails once the engine round-trips it. + expect_edited_dxf: &'static str, + expect_reedited_dxf: &'static str, } fn run_mesh_lifecycle(case: &MeshCase) { @@ -3172,13 +3183,18 @@ mod tests { let dxf = crate::io::load_bytes("mesh.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); let mut gaps = Vec::new(); for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + let expected_digest = if format == "DXF" && !case.expect_edited_dxf.is_empty() { + case.expect_edited_dxf + } else { + case.expect_edited + }; match document.get_entity(handle) { - Some(entity) if (case.digest)(entity) == case.expect_edited => {} + Some(entity) if (case.digest)(entity) == expected_digest => {} Some(entity) => gaps.push(format!("{format}: {}", (case.digest)(entity))), None => gaps.push(format!("{format}: entity missing")), } } - assert!(gaps.is_empty(), "persistence gaps (expected {}): {gaps:#?}", case.expect_edited); + assert!(gaps.is_empty(), "persistence gaps (expected {} / DXF {:?}): {gaps:#?}", case.expect_edited, case.expect_edited_dxf); for (name, mut document) in [("mesh-reedit.dwg", dwg), ("mesh-reedit.dxf", dxf)] { let before = document.get_entity(handle).unwrap().clone(); let mut after = before.clone(); @@ -3192,7 +3208,12 @@ mod tests { acadrust::DxfWriter::new(&document).write_to_vec().unwrap() }; let reopened = crate::io::load_bytes(name, bytes).unwrap(); - assert_eq!((case.digest)(reopened.get_entity(handle).unwrap()), case.expect_reedited, "{name}"); + let expected_digest = if name.ends_with("dxf") && !case.expect_reedited_dxf.is_empty() { + case.expect_reedited_dxf + } else { + case.expect_reedited + }; + assert_eq!((case.digest)(reopened.get_entity(handle).unwrap()), expected_digest, "{name}"); } dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", handle.value())); @@ -3252,6 +3273,8 @@ mod tests { expect_created: "3x3 v9 z0 d0/0 NoSmooth", expect_edited: "3x3 v9 z2.5 d6/6 Cubic", expect_reedited: "3x3 v9 z2.5 d6/6 Cubic", + expect_edited_dxf: "", + expect_reedited_dxf: "", }); } @@ -3293,6 +3316,8 @@ mod tests { expect_created: "v4 f2 z0 last Some((1, 3, 4))", expect_edited: "v5 f3 z3 last Some((3, -4, 5))", expect_reedited: "v5 f3 z3 last Some((3, -4, 5))", + expect_edited_dxf: "", + expect_reedited_dxf: "", }); } @@ -3333,6 +3358,47 @@ mod tests { expect_created: "v4 f1 e1 sub0 crease Some(1.5)", expect_edited: "v5 f2 e1 sub1 crease Some(1.5)", expect_reedited: "v5 f2 e1 sub1 crease Some(1.5)", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn staged_python_helix_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('Helix', axis_base_point=P(0.0, 0.0, 0.0), axis_vector=P(0.0, 0.0, 1.0),\n", + " start_point=P(5.0, 0.0, 0.0), radius=5.0, turns=3.0, turn_height=2.0)\n"), + edit: concat!( + "helix = doc.entities[HANDLE]\n", + "with doc.transaction('Edit helix'):\n", + " helix.turns = 5.0\n", + " helix.handedness = False\n", + "doc.selection = [helix]\n"), + rejects: &[ + ("'radius':0.0", "greater than zero"), + ("'turns':-1.0", "greater than zero"), + ("'turn_height':float('nan')", "greater than zero"), + ("'axis_vector':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'start_point':{'x':0.0,'y':0.0,'z':3.0}", "on the axis"), + ("'spline':{}", "read-only"), + ("'major_version':1", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::Helix(_)), + digest: |entity| match entity { + EntityType::Helix(h) => format!("r{} t{} h{} endz{:.1} ccw{} cp{}", h.radius, h.turns, h.turn_height, + h.spline.control_points.last().map_or(f64::NAN, |p| p.z), h.handedness, + !h.spline.control_points.is_empty()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Helix(h) = entity { h.turns = 6.0; }, + expect_created: "r5 t3 h2 endz6.0 ccwtrue cptrue", + expect_edited: "r5 t5 h2 endz10.0 ccwfalse cptrue", + expect_reedited: "r5 t6 h2 endz10.0 ccwfalse cptrue", + // BLOCKER: the DXF reader parses boolean group 290 as an i16 and + // never applies it, so handedness always reopens counter-clockwise. + expect_edited_dxf: "r5 t5 h2 endz10.0 ccwtrue cptrue", + expect_reedited_dxf: "r5 t6 h2 endz10.0 ccwtrue cptrue", }); } diff --git a/src/entities/helix.rs b/src/entities/helix.rs index e78643321..2d479f17f 100644 --- a/src/entities/helix.rs +++ b/src/entities/helix.rs @@ -602,3 +602,28 @@ mod tests { } } } + +/// Rebuild the derived spline after a scripted create or edit of the +/// generating parameters. `radius` is the top radius; the base radius is the +/// start point's distance from the axis. +pub(crate) fn normalize_scripted_helix(old: Option<&Helix>, new: &mut Helix) -> Result<(), String> { + let unchanged = old.is_some_and(|old| { + old.axis_base_point == new.axis_base_point + && old.start_point == new.start_point + && old.axis_vector == new.axis_vector + && old.radius.to_bits() == new.radius.to_bits() + && old.turns.to_bits() == new.turns.to_bits() + && old.turn_height.to_bits() == new.turn_height.to_bits() + && old.handedness == new.handedness + && old.constraint == new.constraint + }); + if unchanged { + return Ok(()); + } + let top = new.radius; + if rebuild(new, top) { + Ok(()) + } else { + Err("Helix parameters do not define a valid curve".into()) + } +} From 7f65d6d37316c2b5348883102f8de82b1b3dc375 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 14:55:29 +0200 Subject: [PATCH 25/76] Map RasterImage with host-created image definitions Scripts name an image file; the host reads its pixel size, creates and links the ImageDefinition and derives size and the default clip boundary. staged_python_raster_image_lifecycle_over_real_ipc passes every gate in OCS with a real PNG. ACAD_IMAGE_DICT and reactor linkage are not created, so the kind is recorded as integration-tested. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 19 ++++ crates/ocs_plugin_api/src/entity_coverage.rs | 71 ++++++++++++++ docs/plugin-host-model-completion-plan.md | 7 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 13 ++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 97 ++++++++++++++++++- 10 files changed, 207 insertions(+), 13 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 958375596..e931d3ae8 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -791,6 +791,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("PolyfaceMesh", _) | ("Mesh", _) | ("Helix", _) + | ("RasterImage", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 5c5153824..3fe468967 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -388,6 +388,19 @@ "turn_height", "handedness", "constraint" + ], + "RasterImage": [ + "layer", + "insertion_point", + "u_vector", + "v_vector", + "flags", + "clipping_enabled", + "brightness", + "contrast", + "fade", + "clip_boundary", + "file_path" ] }, "readable": { @@ -448,6 +461,12 @@ "spline", "major_version", "maintenance_version" + ], + "RasterImage": [ + "class_version", + "definition_handle", + "definition_reactor_handle", + "size" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 4ea721692..05dcd28b6 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -781,6 +781,54 @@ fn validate_helix( Ok(()) } +/// RasterImage display parameters. The image definition is created and owned +/// by the host; a script supplies the file and the placement. +#[cfg(feature = "host")] +fn validate_raster_image( + old: Option<&crate::host::acadrust::entities::RasterImage>, + new: &crate::host::acadrust::entities::RasterImage, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if let Some(old) = old { + if old.file_path != new.file_path { + return Err("RasterImage.file_path is fixed at creation; the image definition owns it".into()); + } + } else if new.file_path.trim().is_empty() { + return Err("RasterImage.file_path is empty".into()); + } else if new.definition_handle.is_none_or(|handle| handle.is_null()) + && !std::path::Path::new(&new.file_path).is_file() + { + return Err(format!("RasterImage.file_path {:?} is not a readable file", new.file_path)); + } + finite_vector("RasterImage.insertion_point", &new.insertion_point)?; + finite_vector("RasterImage.u_vector", &new.u_vector)?; + finite_vector("RasterImage.v_vector", &new.v_vector)?; + let cross = new.u_vector.cross(&new.v_vector); + if cross.x.hypot(cross.y).hypot(cross.z) <= 1.0e-12 { + return Err("RasterImage.u_vector and v_vector must be nonzero and not parallel".into()); + } + if !new.size.x.is_finite() || !new.size.y.is_finite() || new.size.x < 0.0 || new.size.y < 0.0 { + return Err("RasterImage.size must be finite and non-negative".into()); + } + if new.brightness > 100 || new.contrast > 100 || new.fade > 100 { + return Err("RasterImage brightness, contrast and fade must be within 0..=100".into()); + } + if new.flags.bits() & !0x0f != 0 { + return Err("RasterImage.flags has unknown bits".into()); + } + for (index, vertex) in new.clip_boundary.vertices.iter().enumerate() { + if !vertex.x.is_finite() || !vertex.y.is_finite() { + return Err(format!("RasterImage.clip_boundary.vertices[{index}] must be finite")); + } + } + if new.clipping_enabled && new.clip_boundary.vertices.len() < 2 { + return Err("RasterImage clipping requires at least 2 clip boundary vertices".into()); + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -881,6 +929,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::PolyfaceMesh(value) => validate_polyface_mesh(None, value)?, EntityType::Mesh(value) => validate_mesh(None, value)?, EntityType::Helix(value) => validate_helix(None, value)?, + EntityType::RasterImage(value) => validate_raster_image(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1242,6 +1291,7 @@ pub fn validate_entity_mutation( (EntityType::PolyfaceMesh(old), EntityType::PolyfaceMesh(new)) => validate_polyface_mesh(Some(old), new)?, (EntityType::Mesh(old), EntityType::Mesh(new)) => validate_mesh(Some(old), new)?, (EntityType::Helix(old), EntityType::Helix(new)) => validate_helix(Some(old), new)?, + (EntityType::RasterImage(old), EntityType::RasterImage(new)) => validate_raster_image(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -1505,6 +1555,16 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::RasterImage(value) = entity { + if let Some(handle) = value.definition_handle.filter(|handle| !handle.is_null()) { + if !matches!( + document.objects.get(&handle), + Some(crate::host::acadrust::objects::ObjectType::ImageDefinition(_)) + ) { + return Err(format!("RasterImage image definition {handle:?} does not exist")); + } + } + } if let EntityType::Table(value) = entity { if let Some(handle) = value.table_style_handle.filter(|handle| !handle.is_null()) { if !matches!( @@ -1920,6 +1980,7 @@ mod tests { | "PolyfaceMesh" | "Mesh" | "Helix" + | "RasterImage" ) { assert!( @@ -2183,6 +2244,16 @@ mod tests { "Helix.turn_height".to_owned(), "Helix.handedness".to_owned(), "Helix.constraint".to_owned(), + "RasterImage.insertion_point".to_owned(), + "RasterImage.u_vector".to_owned(), + "RasterImage.v_vector".to_owned(), + "RasterImage.flags".to_owned(), + "RasterImage.clipping_enabled".to_owned(), + "RasterImage.brightness".to_owned(), + "RasterImage.contrast".to_owned(), + "RasterImage.fade".to_owned(), + "RasterImage.clip_boundary".to_owned(), + "RasterImage.file_path".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 128f70c8f..67118543f 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **31 of 43** canvas kinds: 30 +v7. The Python feature has explicit writes for **32 of 43** canvas kinds: 31 are creatable and `Insert` remains update-only. This is **not** 30 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh and Mesh pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader and Helix are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). The other 12 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader and Helix are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 11 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **RasterImage** (queue item 10). Leader is done except for its W +Continue with **Wipeout** (queue item 11). Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -164,3 +164,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `Table`. The generator now handles tuples and `usize`; the host keeps DXF cell merge dimensions in step with `merged_ranges`. `staged_python_table_lifecycle_over_real_ipc` passes the full gate with DWG and DXF reopen. The queue now continues with `PolygonMesh`. - Completed `PolygonMesh`, `PolyfaceMesh` and `Mesh` (queue items 6-8) with one shared `run_mesh_lifecycle` test driver and per-kind host validation. The generator now omits a sub-record's own `EntityCommon` from the scripted form. All three pass the full gate including DWG and DXF reopen with re-edit. The queue now continues with `Helix`. - Mapped `Helix` with a host-rebuilt spline. `run_mesh_lifecycle` gained per-format DXF expectations so an engine gap is pinned by a canary instead of hidden. All gates pass except W for `handedness` in DXF (reader ignores boolean group 290). The queue now continues with `RasterImage`. +- Mapped `RasterImage`. The host reads the image file, creates and links the `ImageDefinition`, and derives `size` and the default clip boundary. The shared lifecycle driver passes every gate in OCS with a real PNG; `ACAD_IMAGE_DICT`/reactor creation is not implemented, so the kind stays integration-tested. The queue now continues with `Wipeout`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 326839968..26e17df25 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **31 of 43 canvas kinds have explicit geometry writes** (30 creatable, plus update-only `Insert`), **11 kinds are complete**, and 12 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **32 of 43 canvas kinds have explicit geometry writes** (31 creatable, plus update-only `Insert`), **11 kinds are complete**, and 11 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -37,7 +37,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | PolyfaceMesh | **Complete** | Writable: `elevation`, `flags`, `normal`, `start_width`, `end_width`, `smooth_surface`, `thickness`, `vertices` (location, flags, bulge, widths, tangent, id) and `faces` (four indices, flags, optional colour). `seqend_handle` is read-only and preserved. Host validation requires at least 3 finite vertices and 1 face, every face index within the vertex count (a negative index hides that edge, 0 marks an unused corner), at least 3 indices per face, and finite widths, thickness and elevation. `staged_python_polyface_mesh_lifecycle_over_real_ipc` passes the full gate: two triangles, a vertex move plus a new vertex and a hidden-edge face in one transaction, canvas wires, six rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Mesh | **Complete** | Writable: `blend_crease`, `subdivision_level`, `vertices`, `faces`, `edges` (start, end, optional crease), `override_option`; `version` is read-only. Host validation requires at least 3 finite vertices, faces of at least 3 in-range indices, edges with distinct in-range endpoints and a finite non-negative crease, and a non-negative subdivision level, so out-of-range topology is rejected before mutation. `staged_python_mesh_lifecycle_over_real_ipc` passes the full gate: a quad with a creased edge, an added vertex and triangle plus subdivision level 1 in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Helix | **Integration-tested / blocked at W by acadrust (DXF handedness)** | Writable generating parameters: `axis_base_point`, `start_point`, `axis_vector`, `radius` (the top radius; the base radius is the start point's distance from the axis), `turns`, `turn_height`, `handedness`, `constraint`. Read-only: `spline`, `major_version`, `maintenance_version`. The host rebuilds the derived NURBS spline with OCS's own helix builder on every scripted create or parameter edit (`normalize_scripted_helix`), so the spline always matches the parameters. Host validation requires finite positive radius, turns and turn height, a nonzero axis and a start point off the axis. `staged_python_helix_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P: creation with a derived spline ending at z = turns × turn height, a turns and handedness edit that regenerates it, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, DXF reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary assertion):** the DXF reader parses boolean group 290 as an `i16` and never applies it, so a clockwise helix reopens counter-clockwise from DXF while DWG keeps it. Not `Complete` until the reader is fixed. | -| RasterImage | Layer-only | Requires image definition and file fixture. | +| RasterImage | **Integration-tested** | Writable: `file_path` (creation only; a later change is rejected because the image definition owns it), `insertion_point`, `u_vector`, `v_vector` (world units per pixel), `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_boundary`. Read-only: `size` (pixel size, taken from the file), `class_version`, `definition_handle`, `definition_reactor_handle`. Creating from a script requires a readable image file: the host reads its pixel size, creates the `ImageDefinition` object, links it, and sets `size` and a whole-image clip boundary from the file. Host validation requires finite, non-parallel `u`/`v` vectors, brightness/contrast/fade within 0..=100, known display flags, finite clip vertices, at least 2 clip vertices when clipping is enabled, and an existing definition. `staged_python_raster_image_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real PNG fixture) passes C/R/E/D/U/I/W/V/P inside OCS: creation with a host-made definition, placement/scale/brightness/contrast/fade and clip edit in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with the definition link and file path intact, re-edit, and three-step undo/redo. **Why not `Complete`:** the host does not create an `ACAD_IMAGE_DICT` entry or `IMAGEDEF_REACTOR` for a scripted image, so other CAD applications may need to repair the linkage; that cannot be verified here. Editing an existing definition (file path, resolution) is out of scope. | | Wipeout | Layer-only | Requires clip and image-definition fixture. | | Underlay | Layer-only | Requires external definition/file fixture. | | Viewport | Layer-only | Requires layout and view-state fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index d1f6a1a02..b96b5f6da 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 31 geometry converters. Other writable properties +canvas kinds outside its 32 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 31-kind generated schema it contains only handle, kind, and layer; +outside the 32-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index b46227440..7c8c22ac6 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 31 mapped kinds. +the 32 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index a6f2e60dd..478dad1e4 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -30,7 +30,8 @@ "PolygonMesh", "PolyfaceMesh", "Mesh", - "Helix" + "Helix", + "RasterImage" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -340,6 +341,16 @@ "major_version": { "read_only": true }, "maintenance_version": { "read_only": true } } + }, + "RasterImage": { + "fields": { + "insertion_point": { "required": true }, + "file_path": { "required": true }, + "class_version": { "read_only": true }, + "size": { "read_only": true, "_comment": "Pixel size of the image; the host takes it from the file." }, + "definition_handle": { "read_only": true }, + "definition_reactor_handle": { "read_only": true } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index bb6537a92..39375f109 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4756,7 +4756,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 31); + assert_eq!(emitted.len(), 32); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 949b57a1b..51eaf2ad7 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -182,7 +182,47 @@ impl<'a> HostSession<'a> { Ok(()) } + /// A scripted RasterImage names a file; the host creates the image + /// definition object it needs (pixel size read from the file) and links it. + fn prepare_scripted_raster_image(&mut self, entity: &mut EntityType) -> Result<(), String> { + let EntityType::RasterImage(image) = entity else { + return Ok(()); + }; + if image.definition_handle.is_some_and(|handle| !handle.is_null()) { + return Ok(()); + } + let (width, height) = image::image_dimensions(&image.file_path) + .map_err(|error| format!("cannot read image {:?}: {error}", image.file_path))?; + let handle = self.document_mut().allocate_handle(); + let mut definition = acadrust::objects::ImageDefinition::with_dimensions( + image.file_path.clone(), + width, + height, + ); + definition.handle = handle; + definition.is_loaded = true; + self.document_mut() + .objects + .insert(handle, acadrust::objects::ObjectType::ImageDefinition(definition)); + image.definition_handle = Some(handle); + // `size` is the image's pixel size, and an untouched clip boundary + // covers the whole image, so both follow the file. + let untouched_clip = image.clip_boundary + == acadrust::entities::ClipBoundary::full_image(image.size.x, image.size.y); + image.size = acadrust::types::Vector2::new(f64::from(width), f64::from(height)); + if untouched_clip { + image.clip_boundary = acadrust::entities::ClipBoundary::full_image( + f64::from(width), + f64::from(height), + ); + } + Ok(()) + } + pub fn add_entity(&mut self, mut entity: EntityType) -> Handle { + if self.prepare_scripted_raster_image(&mut entity).is_err() { + return Handle::NULL; + } if self.normalize_scripted_entity(None, &mut entity).is_err() { return Handle::NULL; } @@ -220,6 +260,7 @@ impl<'a> HostSession<'a> { pub fn add_entities(&mut self, mut entities: Vec) -> Vec { for entity in &mut entities { + let _ = self.prepare_scripted_raster_image(entity); // Creation input was validated by the caller; a degenerate MLine // simply keeps its supplied (unnormalized) vertices. let _ = self.normalize_scripted_entity(None, entity); @@ -2515,7 +2556,7 @@ mod tests { ("'style_name':'Missing'", "does not exist"), ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), ("'scale_factor':0.0", "nonzero"), - ("'flags':64", "unknown bits"), + ("'flags':64", "unknown or out-of-range bits"), ("'flags':3,'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "requires at least 3"), ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "coincide"), ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':float('nan'),'z':0.0}}]", "finite"), @@ -3152,7 +3193,8 @@ mod tests { let _ = std::fs::remove_file(&path); }; - run_script(&mut host, "create", case.create); + let tmp = std::env::temp_dir().to_string_lossy().into_owned(); + run_script(&mut host, "create", &case.create.replace("TMPDIR", &tmp)); let handle = host.document().entities().find(|entity| (case.is_kind)(entity)) .map(|entity| entity.common().handle) .unwrap_or_else(|| panic!("Python did not create the mesh: {}", last_output(&host))); @@ -3160,7 +3202,7 @@ mod tests { assert_eq!((case.digest)(&created), case.expect_created); assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); - run_script(&mut host, "edit", &case.edit.replace("HANDLE", &handle.value().to_string())); + run_script(&mut host, "edit", &case.edit.replace("HANDLE", &handle.value().to_string()).replace("TMPDIR", &tmp)); let expected = host.document().get_entity(handle).unwrap().clone(); assert_eq!((case.digest)(&expected), case.expect_edited, "edit failed: {}", last_output(&host)); assert_eq!(expected.common(), created.common()); @@ -3402,6 +3444,55 @@ mod tests { }); } + #[test] + fn staged_python_raster_image_lifecycle_over_real_ipc() { + image::RgbaImage::from_pixel(8, 4, image::Rgba([200, 30, 30, 255])) + .save(std::env::temp_dir().join("ocs_raster_test.png")) + .unwrap(); + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('RasterImage', file_path='TMPDIR/ocs_raster_test.png',\n", + " insertion_point=P(10.0, 20.0, 0.0), u_vector=P(0.5, 0.0, 0.0), v_vector=P(0.0, 0.5, 0.0))\n"), + edit: concat!( + "img = doc.entities[HANDLE]\n", + "cb = img.clip_boundary\n", + "cb['vertices'] = [{'x': 1.0, 'y': 1.0}, {'x': 7.0, 'y': 3.0}]\n", + "with doc.transaction('Edit image'):\n", + " img.insertion_point = (12.0, 22.0, 0.0)\n", + " img.u_vector = (1.0, 0.0, 0.0)\n", + " img.brightness = 70\n", + " img.contrast = 40\n", + " img.fade = 10\n", + " img.clip_boundary = cb\n", + " img.clipping_enabled = True\n", + "doc.selection = [img]\n"), + rejects: &[ + ("'file_path':'other.png'", "fixed at creation"), + ("'brightness':101", "within 0..=100"), + ("'u_vector':{'x':0.0,'y':0.0,'z':0.0}", "not parallel"), + ("'v_vector':{'x':2.0,'y':0.0,'z':0.0}", "not parallel"), + ("'size':{'x':5.0,'y':5.0}", "read-only"), + ("'definition_handle':5", "read-only"), + ("'flags':64", "unknown or out-of-range bits"), + ("'clip_boundary':{'vertices':[]}", "at least 2"), + ], + is_kind: |entity| matches!(entity, EntityType::RasterImage(_)), + digest: |entity| match entity { + EntityType::RasterImage(i) => format!("{} {:.1},{:.1} u{:.1} b{} c{} f{} clip{} n{} {}x{} def{}", + std::path::Path::new(&i.file_path).file_name().unwrap().to_string_lossy(), + i.insertion_point.x, i.insertion_point.y, i.u_vector.x, i.brightness, i.contrast, i.fade, + i.clipping_enabled, i.clip_boundary.vertices.len(), i.size.x, i.size.y, i.definition_handle.is_some()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::RasterImage(i) = entity { i.brightness = 80; }, + expect_created: "ocs_raster_test.png 10.0,20.0 u0.5 b50 c50 f0 clipfalse n2 8x4 deftrue", + expect_edited: "ocs_raster_test.png 12.0,22.0 u1.0 b70 c40 f10 cliptrue n2 8x4 deftrue", + expect_reedited: "ocs_raster_test.png 12.0,22.0 u1.0 b80 c40 f10 cliptrue n2 8x4 deftrue", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From eae74c24dd3e32d95c2e3da1739ed054328b50dd Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 14:56:34 +0200 Subject: [PATCH 26/76] Restore the MLine flags rejection expectation An earlier bulk edit for the RasterImage flags message also rewrote MLine's test expectation; MLine.flags is a manifest override with its own message. Co-Authored-By: Claude Sonnet 5 --- src/app/plugin_host.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 51eaf2ad7..c6e0ef275 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -2556,7 +2556,7 @@ mod tests { ("'style_name':'Missing'", "does not exist"), ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), ("'scale_factor':0.0", "nonzero"), - ("'flags':64", "unknown or out-of-range bits"), + ("'flags':64", "unknown bits"), ("'flags':3,'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "requires at least 3"), ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "coincide"), ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':float('nan'),'z':0.0}}]", "finite"), From 96e3b54f08975963e846ce0c2b5c682dbbb99e9a Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 15:00:04 +0200 Subject: [PATCH 27/76] Complete Wipeout host model coverage Maps the placement, display and clip-boundary fields with rectangular and polygonal clip validation. staged_python_wipeout_lifecycle_over_real_ipc passes the full gate including DWG and DXF reopen with re-edit. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 20 +++++++ crates/ocs_plugin_api/src/entity_coverage.rs | 60 +++++++++++++++++++ docs/plugin-host-model-completion-plan.md | 11 ++-- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 11 +++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 39 ++++++++++++ 10 files changed, 142 insertions(+), 12 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index e931d3ae8..8002cc97f 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -792,6 +792,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("Mesh", _) | ("Helix", _) | ("RasterImage", _) + | ("Wipeout", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 3fe468967..00600dd0f 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -401,6 +401,21 @@ "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" ] }, "readable": { @@ -467,6 +482,11 @@ "definition_handle", "definition_reactor_handle", "size" + ], + "Wipeout": [ + "class_version", + "definition_handle", + "definition_reactor_handle" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 05dcd28b6..2cd63e379 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -829,6 +829,51 @@ fn validate_raster_image( Ok(()) } +/// Wipeout: a masking region placed like an image; the clip boundary (in +/// normalized image coordinates) defines the masked area. +#[cfg(feature = "host")] +fn validate_wipeout( + old: Option<&crate::host::acadrust::entities::Wipeout>, + new: &crate::host::acadrust::entities::Wipeout, +) -> Result<(), String> { + use crate::host::acadrust::entities::WipeoutClipType; + if old == Some(new) { + return Ok(()); + } + finite_vector("Wipeout.insertion_point", &new.insertion_point)?; + finite_vector("Wipeout.u_vector", &new.u_vector)?; + finite_vector("Wipeout.v_vector", &new.v_vector)?; + let cross = new.u_vector.cross(&new.v_vector); + if cross.x.hypot(cross.y).hypot(cross.z) <= 1.0e-12 { + return Err("Wipeout.u_vector and v_vector must be nonzero and not parallel".into()); + } + if !new.size.x.is_finite() || !new.size.y.is_finite() || new.size.x <= 0.0 || new.size.y <= 0.0 { + return Err("Wipeout.size must be finite and greater than zero".into()); + } + if new.brightness > 100 || new.contrast > 100 || new.fade > 100 { + return Err("Wipeout brightness, contrast and fade must be within 0..=100".into()); + } + if new.flags.bits() & !0x0f != 0 { + return Err("Wipeout.flags has unknown bits".into()); + } + for (index, vertex) in new.clip_boundary_vertices.iter().enumerate() { + if !vertex.x.is_finite() || !vertex.y.is_finite() { + return Err(format!("Wipeout.clip_boundary_vertices[{index}] must be finite")); + } + } + let count = new.clip_boundary_vertices.len(); + match new.clip_type { + WipeoutClipType::Rectangular if count != 2 => { + return Err("a rectangular Wipeout needs exactly 2 clip boundary vertices".into()); + } + WipeoutClipType::Polygonal if count < 3 => { + return Err("a polygonal Wipeout needs at least 3 clip boundary vertices".into()); + } + _ => {} + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -930,6 +975,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::Mesh(value) => validate_mesh(None, value)?, EntityType::Helix(value) => validate_helix(None, value)?, EntityType::RasterImage(value) => validate_raster_image(None, value)?, + EntityType::Wipeout(value) => validate_wipeout(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1292,6 +1338,7 @@ pub fn validate_entity_mutation( (EntityType::Mesh(old), EntityType::Mesh(new)) => validate_mesh(Some(old), new)?, (EntityType::Helix(old), EntityType::Helix(new)) => validate_helix(Some(old), new)?, (EntityType::RasterImage(old), EntityType::RasterImage(new)) => validate_raster_image(Some(old), new)?, + (EntityType::Wipeout(old), EntityType::Wipeout(new)) => validate_wipeout(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -1981,6 +2028,7 @@ mod tests { | "Mesh" | "Helix" | "RasterImage" + | "Wipeout" ) { assert!( @@ -2254,6 +2302,18 @@ mod tests { "RasterImage.fade".to_owned(), "RasterImage.clip_boundary".to_owned(), "RasterImage.file_path".to_owned(), + "Wipeout.insertion_point".to_owned(), + "Wipeout.u_vector".to_owned(), + "Wipeout.v_vector".to_owned(), + "Wipeout.size".to_owned(), + "Wipeout.flags".to_owned(), + "Wipeout.clipping_enabled".to_owned(), + "Wipeout.brightness".to_owned(), + "Wipeout.contrast".to_owned(), + "Wipeout.fade".to_owned(), + "Wipeout.clip_mode".to_owned(), + "Wipeout.clip_type".to_owned(), + "Wipeout.clip_boundary_vertices".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 67118543f..eec661d83 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **32 of 43** canvas kinds: 31 -are creatable and `Insert` remains update-only. This is **not** 30 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh and Mesh pass every completion gate. +v7. The Python feature has explicit writes for **33 of 43** canvas kinds: 32 +are creatable and `Insert` remains update-only. This is **not** 33 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh and Wipeout pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader and Helix are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 11 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader and Helix are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 10 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **Wipeout** (queue item 11). Leader is done except for its W +Continue with **Underlay** (queue item 12). Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -165,3 +165,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `PolygonMesh`, `PolyfaceMesh` and `Mesh` (queue items 6-8) with one shared `run_mesh_lifecycle` test driver and per-kind host validation. The generator now omits a sub-record's own `EntityCommon` from the scripted form. All three pass the full gate including DWG and DXF reopen with re-edit. The queue now continues with `Helix`. - Mapped `Helix` with a host-rebuilt spline. `run_mesh_lifecycle` gained per-format DXF expectations so an engine gap is pinned by a canary instead of hidden. All gates pass except W for `handedness` in DXF (reader ignores boolean group 290). The queue now continues with `RasterImage`. - Mapped `RasterImage`. The host reads the image file, creates and links the `ImageDefinition`, and derives `size` and the default clip boundary. The shared lifecycle driver passes every gate in OCS with a real PNG; `ACAD_IMAGE_DICT`/reactor creation is not implemented, so the kind stays integration-tested. The queue now continues with `Wipeout`. +- Completed `Wipeout` (queue item 11) with rectangular and polygonal clip masks through the shared lifecycle driver; scripted creation matches OCS's native command. The queue now continues with `Underlay`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 26e17df25..addb05162 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **32 of 43 canvas kinds have explicit geometry writes** (31 creatable, plus update-only `Insert`), **11 kinds are complete**, and 11 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **33 of 43 canvas kinds have explicit geometry writes** (32 creatable, plus update-only `Insert`), **12 kinds are complete**, and 10 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -38,7 +38,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Mesh | **Complete** | Writable: `blend_crease`, `subdivision_level`, `vertices`, `faces`, `edges` (start, end, optional crease), `override_option`; `version` is read-only. Host validation requires at least 3 finite vertices, faces of at least 3 in-range indices, edges with distinct in-range endpoints and a finite non-negative crease, and a non-negative subdivision level, so out-of-range topology is rejected before mutation. `staged_python_mesh_lifecycle_over_real_ipc` passes the full gate: a quad with a creased edge, an added vertex and triangle plus subdivision level 1 in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Helix | **Integration-tested / blocked at W by acadrust (DXF handedness)** | Writable generating parameters: `axis_base_point`, `start_point`, `axis_vector`, `radius` (the top radius; the base radius is the start point's distance from the axis), `turns`, `turn_height`, `handedness`, `constraint`. Read-only: `spline`, `major_version`, `maintenance_version`. The host rebuilds the derived NURBS spline with OCS's own helix builder on every scripted create or parameter edit (`normalize_scripted_helix`), so the spline always matches the parameters. Host validation requires finite positive radius, turns and turn height, a nonzero axis and a start point off the axis. `staged_python_helix_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P: creation with a derived spline ending at z = turns × turn height, a turns and handedness edit that regenerates it, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, DXF reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary assertion):** the DXF reader parses boolean group 290 as an `i16` and never applies it, so a clockwise helix reopens counter-clockwise from DXF while DWG keeps it. Not `Complete` until the reader is fixed. | | RasterImage | **Integration-tested** | Writable: `file_path` (creation only; a later change is rejected because the image definition owns it), `insertion_point`, `u_vector`, `v_vector` (world units per pixel), `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_boundary`. Read-only: `size` (pixel size, taken from the file), `class_version`, `definition_handle`, `definition_reactor_handle`. Creating from a script requires a readable image file: the host reads its pixel size, creates the `ImageDefinition` object, links it, and sets `size` and a whole-image clip boundary from the file. Host validation requires finite, non-parallel `u`/`v` vectors, brightness/contrast/fade within 0..=100, known display flags, finite clip vertices, at least 2 clip vertices when clipping is enabled, and an existing definition. `staged_python_raster_image_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real PNG fixture) passes C/R/E/D/U/I/W/V/P inside OCS: creation with a host-made definition, placement/scale/brightness/contrast/fade and clip edit in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with the definition link and file path intact, re-edit, and three-step undo/redo. **Why not `Complete`:** the host does not create an `ACAD_IMAGE_DICT` entry or `IMAGEDEF_REACTOR` for a scripted image, so other CAD applications may need to repair the linkage; that cannot be verified here. Editing an existing definition (file path, resolution) is out of scope. | -| Wipeout | Layer-only | Requires clip and image-definition fixture. | +| Wipeout | **Complete** | Writable: `insertion_point`, `u_vector`, `v_vector` (the region's world extent), `size`, `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_mode`, `clip_type` and `clip_boundary_vertices` (normalized image coordinates, -0.5..0.5). Read-only: `class_version`, `definition_handle`, `definition_reactor_handle`. Scripted creation matches OCS's native WIPEOUT command, which also leaves the definition link unset. Host validation requires finite non-parallel `u`/`v` vectors, a positive `size`, brightness/contrast/fade within 0..=100, known flags, finite clip vertices, exactly 2 vertices for a rectangular clip and at least 3 for a polygonal one. `staged_python_wipeout_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: rectangular creation, a switch to a three-vertex polygonal inside-clip with a move in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Underlay | Layer-only | Requires external definition/file fixture. | | Viewport | Layer-only | Requires layout and view-state fixture. | | ViewBorder | Layer-only | Requires linked view records. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index b96b5f6da..cdda2f516 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 32 geometry converters. Other writable properties +canvas kinds outside its 33 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 32-kind generated schema it contains only handle, kind, and layer; +outside the 33-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 7c8c22ac6..7e13f7c5b 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 32 mapped kinds. +the 33 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 478dad1e4..a806b57af 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -31,7 +31,8 @@ "PolyfaceMesh", "Mesh", "Helix", - "RasterImage" + "RasterImage", + "Wipeout" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -351,6 +352,14 @@ "definition_handle": { "read_only": true }, "definition_reactor_handle": { "read_only": true } } + }, + "Wipeout": { + "fields": { + "insertion_point": { "required": true }, + "class_version": { "read_only": true }, + "definition_handle": { "read_only": true }, + "definition_reactor_handle": { "read_only": true } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 39375f109..d1bec2949 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4756,7 +4756,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 32); + assert_eq!(emitted.len(), 33); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index c6e0ef275..42ca7bb4e 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3493,6 +3493,45 @@ mod tests { }); } + #[test] + fn staged_python_wipeout_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('Wipeout', insertion_point=P(0.0, 0.0, 0.0),\n", + " u_vector=P(10.0, 0.0, 0.0), v_vector=P(0.0, 6.0, 0.0))\n"), + edit: concat!( + "w = doc.entities[HANDLE]\n", + "with doc.transaction('Edit wipeout'):\n", + " w.clip_type = 'Polygonal'\n", + " w.clip_boundary_vertices = [{'x': -0.5, 'y': -0.5}, {'x': 0.5, 'y': -0.5}, {'x': 0.0, 'y': 0.5}]\n", + " w.insertion_point = (2.0, 3.0, 0.0)\n", + " w.clip_mode = 'Inside'\n", + "doc.selection = [w]\n"), + rejects: &[ + ("'clip_boundary_vertices':[{'x':0.0,'y':0.0}]", "at least 3"), + ("'clip_type':'Rectangular'", "exactly 2"), + ("'u_vector':{'x':0.0,'y':0.0,'z':0.0}", "not parallel"), + ("'brightness':200", "within 0..=100"), + ("'size':{'x':0.0,'y':1.0}", "greater than zero"), + ("'insertion_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'definition_handle':5", "read-only"), + ("'clip_type':'Circle'", "unsupported"), + ], + is_kind: |entity| matches!(entity, EntityType::Wipeout(_)), + digest: |entity| match entity { + EntityType::Wipeout(w) => format!("{:?} {:?} n{} at{:.1},{:.1} u{:.1} v{:.1}", w.clip_type, w.clip_mode, + w.clip_boundary_vertices.len(), w.insertion_point.x, w.insertion_point.y, w.u_vector.x, w.v_vector.y), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Wipeout(w) = entity { w.u_vector.x = 12.0; }, + expect_created: "Rectangular Outside n2 at0.0,0.0 u10.0 v6.0", + expect_edited: "Polygonal Inside n3 at2.0,3.0 u10.0 v6.0", + expect_reedited: "Polygonal Inside n3 at2.0,3.0 u12.0 v6.0", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 2e4d77dc098da8e5cc0ee628edd9f9b4207776af Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 15:05:11 +0200 Subject: [PATCH 28/76] Map Underlay in the Python host model; record DXF rotation blocker Scripts place an underlay against an existing UnderlayDefinition; the host checks that the definition exists and matches the underlay type. staged_python_underlay_lifecycle_over_real_ipc passes every gate except DXF persistence: acadrust's reader returns the degree-valued rotation unconverted and repeated saves compound it. Pinned by canary expectations. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 16 ++++ crates/ocs_plugin_api/src/entity_coverage.rs | 79 +++++++++++++++++++ docs/plugin-host-model-completion-plan.md | 9 ++- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 9 ++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 56 ++++++++++++- 10 files changed, 170 insertions(+), 12 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 8002cc97f..8695bb949 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -793,6 +793,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("Helix", _) | ("RasterImage", _) | ("Wipeout", _) + | ("Underlay", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 00600dd0f..edd8d6a4d 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -416,6 +416,22 @@ "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" ] }, "readable": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 2cd63e379..c086426f4 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -874,6 +874,51 @@ fn validate_wipeout( Ok(()) } +/// Underlay placement and display. The file and page live on the referenced +/// `UnderlayDefinition`, which a script names by handle at creation. +#[cfg(feature = "host")] +fn validate_underlay( + old: Option<&crate::host::acadrust::entities::Underlay>, + new: &crate::host::acadrust::entities::Underlay, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if let Some(old) = old { + if old.definition_handle != new.definition_handle { + return Err("Underlay.definition_handle is fixed at creation".into()); + } + if old.underlay_type != new.underlay_type { + return Err("Underlay.underlay_type is fixed at creation".into()); + } + } + finite_vector("Underlay.insertion_point", &new.insertion_point)?; + solid_normal("Underlay.normal", &new.normal)?; + for (name, value) in [("x_scale", new.x_scale), ("y_scale", new.y_scale), ("z_scale", new.z_scale)] { + if !value.is_finite() || value == 0.0 { + return Err(format!("Underlay.{name} must be finite and nonzero")); + } + } + if !new.rotation.is_finite() { + return Err("Underlay.rotation must be finite".into()); + } + if new.contrast > 100 || new.fade > 100 { + return Err("Underlay contrast and fade must be within 0..=100".into()); + } + if new.flags.bits() & !0x0f != 0 { + return Err("Underlay.flags has unknown bits".into()); + } + for (index, vertex) in new.clip_boundary_vertices.iter().enumerate() { + if !vertex.x.is_finite() || !vertex.y.is_finite() { + return Err(format!("Underlay.clip_boundary_vertices[{index}] must be finite")); + } + } + if !new.clip_boundary_vertices.is_empty() && new.clip_boundary_vertices.len() < 2 { + return Err("Underlay clip boundary needs at least 2 vertices".into()); + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -976,6 +1021,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::Helix(value) => validate_helix(None, value)?, EntityType::RasterImage(value) => validate_raster_image(None, value)?, EntityType::Wipeout(value) => validate_wipeout(None, value)?, + EntityType::Underlay(value) => validate_underlay(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1339,6 +1385,7 @@ pub fn validate_entity_mutation( (EntityType::Helix(old), EntityType::Helix(new)) => validate_helix(Some(old), new)?, (EntityType::RasterImage(old), EntityType::RasterImage(new)) => validate_raster_image(Some(old), new)?, (EntityType::Wipeout(old), EntityType::Wipeout(new)) => validate_wipeout(Some(old), new)?, + (EntityType::Underlay(old), EntityType::Underlay(new)) => validate_underlay(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -1602,6 +1649,24 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::Underlay(value) = entity { + match document.objects.get(&value.definition_handle) { + Some(crate::host::acadrust::objects::ObjectType::UnderlayDefinition(definition)) => { + if definition.underlay_type != value.underlay_type { + return Err(format!( + "Underlay type {:?} does not match its definition ({:?})", + value.underlay_type, definition.underlay_type + )); + } + } + _ => { + return Err(format!( + "Underlay definition {:?} does not exist", + value.definition_handle + )); + } + } + } if let EntityType::RasterImage(value) = entity { if let Some(handle) = value.definition_handle.filter(|handle| !handle.is_null()) { if !matches!( @@ -2029,6 +2094,7 @@ mod tests { | "Helix" | "RasterImage" | "Wipeout" + | "Underlay" ) { assert!( @@ -2314,6 +2380,19 @@ mod tests { "Wipeout.clip_mode".to_owned(), "Wipeout.clip_type".to_owned(), "Wipeout.clip_boundary_vertices".to_owned(), + "Underlay.underlay_type".to_owned(), + "Underlay.definition_handle".to_owned(), + "Underlay.insertion_point".to_owned(), + "Underlay.x_scale".to_owned(), + "Underlay.y_scale".to_owned(), + "Underlay.z_scale".to_owned(), + "Underlay.rotation".to_owned(), + "Underlay.normal".to_owned(), + "Underlay.flags".to_owned(), + "Underlay.contrast".to_owned(), + "Underlay.fade".to_owned(), + "Underlay.clip_boundary_vertices".to_owned(), + "Underlay.clip_inverted".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index eec661d83..47847708e 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **33 of 43** canvas kinds: 32 -are creatable and `Insert` remains update-only. This is **not** 33 completed +v7. The Python feature has explicit writes for **34 of 43** canvas kinds: 33 +are creatable and `Insert` remains update-only. This is **not** 34 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh and Wipeout pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader and Helix are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 10 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix and Underlay are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 9 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **Underlay** (queue item 12). Leader is done except for its W +Continue with **Viewport** (queue item 13). Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -166,3 +166,4 @@ exact queue above and update statuses with evidence after each increment. - Mapped `Helix` with a host-rebuilt spline. `run_mesh_lifecycle` gained per-format DXF expectations so an engine gap is pinned by a canary instead of hidden. All gates pass except W for `handedness` in DXF (reader ignores boolean group 290). The queue now continues with `RasterImage`. - Mapped `RasterImage`. The host reads the image file, creates and links the `ImageDefinition`, and derives `size` and the default clip boundary. The shared lifecycle driver passes every gate in OCS with a real PNG; `ACAD_IMAGE_DICT`/reactor creation is not implemented, so the kind stays integration-tested. The queue now continues with `Wipeout`. - Completed `Wipeout` (queue item 11) with rectangular and polygonal clip masks through the shared lifecycle driver; scripted creation matches OCS's native command. The queue now continues with `Underlay`. +- Mapped `Underlay` against a fixture PDF `UnderlayDefinition`. All gates pass except DXF W: the reader does not convert the degree-valued rotation back to radians, and repeated DXF round trips compound the error (pinned by a canary). The queue now continues with `Viewport`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index addb05162..6da48742b 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **33 of 43 canvas kinds have explicit geometry writes** (32 creatable, plus update-only `Insert`), **12 kinds are complete**, and 10 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **34 of 43 canvas kinds have explicit geometry writes** (33 creatable, plus update-only `Insert`), **12 kinds are complete**, and 9 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -39,7 +39,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Helix | **Integration-tested / blocked at W by acadrust (DXF handedness)** | Writable generating parameters: `axis_base_point`, `start_point`, `axis_vector`, `radius` (the top radius; the base radius is the start point's distance from the axis), `turns`, `turn_height`, `handedness`, `constraint`. Read-only: `spline`, `major_version`, `maintenance_version`. The host rebuilds the derived NURBS spline with OCS's own helix builder on every scripted create or parameter edit (`normalize_scripted_helix`), so the spline always matches the parameters. Host validation requires finite positive radius, turns and turn height, a nonzero axis and a start point off the axis. `staged_python_helix_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P: creation with a derived spline ending at z = turns × turn height, a turns and handedness edit that regenerates it, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, DXF reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary assertion):** the DXF reader parses boolean group 290 as an `i16` and never applies it, so a clockwise helix reopens counter-clockwise from DXF while DWG keeps it. Not `Complete` until the reader is fixed. | | RasterImage | **Integration-tested** | Writable: `file_path` (creation only; a later change is rejected because the image definition owns it), `insertion_point`, `u_vector`, `v_vector` (world units per pixel), `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_boundary`. Read-only: `size` (pixel size, taken from the file), `class_version`, `definition_handle`, `definition_reactor_handle`. Creating from a script requires a readable image file: the host reads its pixel size, creates the `ImageDefinition` object, links it, and sets `size` and a whole-image clip boundary from the file. Host validation requires finite, non-parallel `u`/`v` vectors, brightness/contrast/fade within 0..=100, known display flags, finite clip vertices, at least 2 clip vertices when clipping is enabled, and an existing definition. `staged_python_raster_image_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real PNG fixture) passes C/R/E/D/U/I/W/V/P inside OCS: creation with a host-made definition, placement/scale/brightness/contrast/fade and clip edit in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with the definition link and file path intact, re-edit, and three-step undo/redo. **Why not `Complete`:** the host does not create an `ACAD_IMAGE_DICT` entry or `IMAGEDEF_REACTOR` for a scripted image, so other CAD applications may need to repair the linkage; that cannot be verified here. Editing an existing definition (file path, resolution) is out of scope. | | Wipeout | **Complete** | Writable: `insertion_point`, `u_vector`, `v_vector` (the region's world extent), `size`, `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_mode`, `clip_type` and `clip_boundary_vertices` (normalized image coordinates, -0.5..0.5). Read-only: `class_version`, `definition_handle`, `definition_reactor_handle`. Scripted creation matches OCS's native WIPEOUT command, which also leaves the definition link unset. Host validation requires finite non-parallel `u`/`v` vectors, a positive `size`, brightness/contrast/fade within 0..=100, known flags, finite clip vertices, exactly 2 vertices for a rectangular clip and at least 3 for a polygonal one. `staged_python_wipeout_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: rectangular creation, a switch to a three-vertex polygonal inside-clip with a move in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | -| Underlay | Layer-only | Requires external definition/file fixture. | +| Underlay | **Integration-tested / blocked at W by acadrust (DXF rotation units)** | Writable: `underlay_type` and `definition_handle` (both fixed by the definition: creation names an existing `UnderlayDefinition`, and a mismatched type or missing definition is rejected), `insertion_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `flags`, `contrast`, `fade`, `clip_boundary_vertices`, `clip_inverted`. The file path and page live on the definition object, which scripts cannot create or list yet; they reference one the drawing already owns. Host validation requires finite nonzero scales, a finite rotation, a nonzero normal, contrast/fade within 0..=100, known flags, finite clip vertices (none, or at least 2) and an existing definition of the same type. `staged_python_underlay_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle` with a fixture PDF definition) passes C/R/E/D/U/I/V/P and DWG W: creation, a placement/scale/rotation/contrast/fade/clip transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by canary assertions):** DXF stores the rotation in degrees but the reader returns it unconverted, so 0.5 rad reopens as 28.6 and each further DXF save-reopen compounds it (1641.4 after a second), silently corrupting the rotation. Not `Complete` until the reader converts degrees back to radians. Not covered: creating definitions from a file path, DWF/DGN fixtures. | | Viewport | Layer-only | Requires layout and view-state fixture. | | ViewBorder | Layer-only | Requires linked view records. | | SectionSymbol | Layer-only | Requires section-view style and point records. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index cdda2f516..8e265b4a0 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 33 geometry converters. Other writable properties +canvas kinds outside its 34 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 33-kind generated schema it contains only handle, kind, and layer; +outside the 34-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 7e13f7c5b..2223de060 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 33 mapped kinds. +the 34 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index a806b57af..1ae0f7ed2 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -32,7 +32,8 @@ "Mesh", "Helix", "RasterImage", - "Wipeout" + "Wipeout", + "Underlay" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -360,6 +361,12 @@ "definition_handle": { "read_only": true }, "definition_reactor_handle": { "read_only": true } } + }, + "Underlay": { + "fields": { + "definition_handle": { "required": true }, + "insertion_point": { "required": true } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index d1bec2949..694281bb7 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4756,7 +4756,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 33); + assert_eq!(emitted.len(), 34); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 42ca7bb4e..78803561e 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3194,7 +3194,16 @@ mod tests { }; let tmp = std::env::temp_dir().to_string_lossy().into_owned(); - run_script(&mut host, "create", &case.create.replace("TMPDIR", &tmp)); + let mut create_script = case.create.replace("TMPDIR", &tmp); + if create_script.contains("DEFHANDLE") { + // Scripts reference an underlay definition that the drawing owns. + let handle = host.document_mut().allocate_handle(); + let mut definition = acadrust::objects::UnderlayDefinition::pdf("plan.pdf", "1"); + definition.handle = handle; + host.document_mut().objects.insert(handle, acadrust::objects::ObjectType::UnderlayDefinition(definition)); + create_script = create_script.replace("DEFHANDLE", &handle.value().to_string()); + } + run_script(&mut host, "create", &create_script); let handle = host.document().entities().find(|entity| (case.is_kind)(entity)) .map(|entity| entity.common().handle) .unwrap_or_else(|| panic!("Python did not create the mesh: {}", last_output(&host))); @@ -3532,6 +3541,51 @@ mod tests { }); } + #[test] + fn staged_python_underlay_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('Underlay', underlay_type='Pdf', definition_handle=DEFHANDLE,\n", + " insertion_point=P(5.0, 5.0, 0.0), x_scale=2.0, y_scale=2.0)\n"), + edit: concat!( + "u = doc.entities[HANDLE]\n", + "with doc.transaction('Edit underlay'):\n", + " u.insertion_point = (8.0, 9.0, 0.0)\n", + " u.x_scale = 3.0\n", + " u.y_scale = 3.0\n", + " u.rotation = 0.5\n", + " u.contrast = 70\n", + " u.fade = 20\n", + " u.clip_boundary_vertices = [{'x': 0.0, 'y': 0.0}, {'x': 4.0, 'y': 3.0}]\n", + "doc.selection = [u]\n"), + rejects: &[ + ("'definition_handle':999999", "does not exist"), + ("'underlay_type':'Dwf'", "does not match its definition"), + ("'x_scale':0.0", "nonzero"), + ("'rotation':float('nan')", "finite"), + ("'contrast':101", "within 0..=100"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'clip_boundary_vertices':[{'x':1.0,'y':1.0}]", "at least 2"), + ("'flags':64", "unknown or out-of-range bits"), + ], + is_kind: |entity| matches!(entity, EntityType::Underlay(_)), + digest: |entity| match entity { + EntityType::Underlay(u) => format!("{:?} at{:.1},{:.1} s{:.1} r{:.1} c{} f{} clip{}", u.underlay_type, + u.insertion_point.x, u.insertion_point.y, u.x_scale, u.rotation, u.contrast, u.fade, + u.clip_boundary_vertices.len()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Underlay(u) = entity { u.x_scale = 4.0; }, + expect_created: "Pdf at5.0,5.0 s2.0 r0.0 c100 f0 clip0", + expect_edited: "Pdf at8.0,9.0 s3.0 r0.5 c70 f20 clip2", + expect_reedited: "Pdf at8.0,9.0 s4.0 r0.5 c70 f20 clip2", + // BLOCKER: DXF stores the rotation in degrees and the reader returns + // it unconverted, so 0.5 rad reopens as 28.6 and a second save-reopen compounds it to 1641.4. + expect_edited_dxf: "Pdf at8.0,9.0 s3.0 r28.6 c70 f20 clip2", + expect_reedited_dxf: "Pdf at8.0,9.0 s4.0 r1641.4 c70 f20 clip2", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 9075e53511e32a644cef10fe4a189fc6f04e5a6d Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 15:09:23 +0200 Subject: [PATCH 29/76] Map Viewport in the Python host model Curated view fields are writable; the owner must be a paper-space block and frozen layers must exist. staged_python_viewport_lifecycle_over_real_ipc passes every gate except a paper-space canvas oracle, so the kind is recorded as integration-tested. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 50 ++++++++++++++ crates/ocs_plugin_api/src/entity_coverage.rs | 66 +++++++++++++++++++ docs/plugin-host-model-completion-plan.md | 9 +-- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 38 ++++++++++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 57 +++++++++++++++- 10 files changed, 219 insertions(+), 14 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 8695bb949..15b84f9f2 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -794,6 +794,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("RasterImage", _) | ("Wipeout", _) | ("Underlay", _) + | ("Viewport", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index edd8d6a4d..978082943 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -432,6 +432,26 @@ "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", + "custom_scale", + "frozen_layers", + "render_mode", + "circle_sides", + "ucs_icon_visible" ] }, "readable": { @@ -503,6 +523,36 @@ "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" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index c086426f4..6d44d4385 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -919,6 +919,36 @@ fn validate_underlay( Ok(()) } +/// Viewport view state. Owner and layer-handle references are checked with the +/// other references; this validates the numbers. +#[cfg(feature = "host")] +fn validate_viewport( + old: Option<&crate::host::acadrust::entities::Viewport>, + new: &crate::host::acadrust::entities::Viewport, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + finite_vector("Viewport.center", &new.center)?; + finite_vector("Viewport.view_center", &new.view_center)?; + finite_vector("Viewport.view_target", &new.view_target)?; + solid_normal("Viewport.view_direction", &new.view_direction)?; + for (name, value) in [("width", new.width), ("height", new.height), ("view_height", new.view_height), ("lens_length", new.lens_length), ("custom_scale", new.custom_scale)] { + if !value.is_finite() || value <= 0.0 { + return Err(format!("Viewport.{name} must be finite and greater than zero")); + } + } + for (name, value) in [("front_clip_z", new.front_clip_z), ("back_clip_z", new.back_clip_z), ("twist_angle", new.twist_angle)] { + if !value.is_finite() { + return Err(format!("Viewport.{name} must be finite")); + } + } + if new.circle_sides < 3 { + return Err("Viewport.circle_sides must be at least 3".into()); + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -1022,6 +1052,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::RasterImage(value) => validate_raster_image(None, value)?, EntityType::Wipeout(value) => validate_wipeout(None, value)?, EntityType::Underlay(value) => validate_underlay(None, value)?, + EntityType::Viewport(value) => validate_viewport(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1386,6 +1417,7 @@ pub fn validate_entity_mutation( (EntityType::RasterImage(old), EntityType::RasterImage(new)) => validate_raster_image(Some(old), new)?, (EntityType::Wipeout(old), EntityType::Wipeout(new)) => validate_wipeout(Some(old), new)?, (EntityType::Underlay(old), EntityType::Underlay(new)) => validate_underlay(Some(old), new)?, + (EntityType::Viewport(old), EntityType::Viewport(new)) => validate_viewport(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -1649,6 +1681,22 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::Viewport(value) = entity { + let owner = value.common.owner_handle; + let in_paper_space = !owner.is_null() + && document + .block_records + .iter() + .any(|record| record.handle == owner && record.is_paper_space()); + if !in_paper_space { + return Err("Viewport must be owned by a paper-space layout block".into()); + } + for handle in &value.frozen_layers { + if !document.layers.iter().any(|layer| layer.handle == *handle) { + return Err(format!("Viewport frozen layer {handle:?} does not exist")); + } + } + } if let EntityType::Underlay(value) = entity { match document.objects.get(&value.definition_handle) { Some(crate::host::acadrust::objects::ObjectType::UnderlayDefinition(definition)) => { @@ -2095,6 +2143,7 @@ mod tests { | "RasterImage" | "Wipeout" | "Underlay" + | "Viewport" ) { assert!( @@ -2393,6 +2442,23 @@ mod tests { "Underlay.fade".to_owned(), "Underlay.clip_boundary_vertices".to_owned(), "Underlay.clip_inverted".to_owned(), + "Viewport.center".to_owned(), + "Viewport.width".to_owned(), + "Viewport.height".to_owned(), + "Viewport.status".to_owned(), + "Viewport.view_center".to_owned(), + "Viewport.view_direction".to_owned(), + "Viewport.view_target".to_owned(), + "Viewport.lens_length".to_owned(), + "Viewport.front_clip_z".to_owned(), + "Viewport.back_clip_z".to_owned(), + "Viewport.view_height".to_owned(), + "Viewport.twist_angle".to_owned(), + "Viewport.custom_scale".to_owned(), + "Viewport.frozen_layers".to_owned(), + "Viewport.render_mode".to_owned(), + "Viewport.circle_sides".to_owned(), + "Viewport.ucs_icon_visible".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 47847708e..752441150 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **34 of 43** canvas kinds: 33 -are creatable and `Insert` remains update-only. This is **not** 34 completed +v7. The Python feature has explicit writes for **35 of 43** canvas kinds: 34 +are creatable and `Insert` remains update-only. This is **not** 35 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh and Wipeout pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix and Underlay are mapped and integration-tested but blocked at W by acadrust persistence gaps (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 9 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix and Underlay are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 8 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **Viewport** (queue item 13). Leader is done except for its W +Continue with **ViewBorder** (queue item 14). Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -167,3 +167,4 @@ exact queue above and update statuses with evidence after each increment. - Mapped `RasterImage`. The host reads the image file, creates and links the `ImageDefinition`, and derives `size` and the default clip boundary. The shared lifecycle driver passes every gate in OCS with a real PNG; `ACAD_IMAGE_DICT`/reactor creation is not implemented, so the kind stays integration-tested. The queue now continues with `Wipeout`. - Completed `Wipeout` (queue item 11) with rectangular and polygonal clip masks through the shared lifecycle driver; scripted creation matches OCS's native command. The queue now continues with `Underlay`. - Mapped `Underlay` against a fixture PDF `UnderlayDefinition`. All gates pass except DXF W: the reader does not convert the degree-valued rotation back to radians, and repeated DXF round trips compound the error (pinned by a canary). The queue now continues with `Viewport`. +- Mapped `Viewport` with a paper-space owner rule and frozen-layer reference validation. Every gate except a paper-space canvas oracle passes through the shared driver; the kind stays integration-tested. The queue now continues with `ViewBorder`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 6da48742b..cd29a97ba 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **34 of 43 canvas kinds have explicit geometry writes** (33 creatable, plus update-only `Insert`), **12 kinds are complete**, and 9 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **35 of 43 canvas kinds have explicit geometry writes** (34 creatable, plus update-only `Insert`), **12 kinds are complete**, and 8 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -40,7 +40,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | RasterImage | **Integration-tested** | Writable: `file_path` (creation only; a later change is rejected because the image definition owns it), `insertion_point`, `u_vector`, `v_vector` (world units per pixel), `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_boundary`. Read-only: `size` (pixel size, taken from the file), `class_version`, `definition_handle`, `definition_reactor_handle`. Creating from a script requires a readable image file: the host reads its pixel size, creates the `ImageDefinition` object, links it, and sets `size` and a whole-image clip boundary from the file. Host validation requires finite, non-parallel `u`/`v` vectors, brightness/contrast/fade within 0..=100, known display flags, finite clip vertices, at least 2 clip vertices when clipping is enabled, and an existing definition. `staged_python_raster_image_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real PNG fixture) passes C/R/E/D/U/I/W/V/P inside OCS: creation with a host-made definition, placement/scale/brightness/contrast/fade and clip edit in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with the definition link and file path intact, re-edit, and three-step undo/redo. **Why not `Complete`:** the host does not create an `ACAD_IMAGE_DICT` entry or `IMAGEDEF_REACTOR` for a scripted image, so other CAD applications may need to repair the linkage; that cannot be verified here. Editing an existing definition (file path, resolution) is out of scope. | | Wipeout | **Complete** | Writable: `insertion_point`, `u_vector`, `v_vector` (the region's world extent), `size`, `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_mode`, `clip_type` and `clip_boundary_vertices` (normalized image coordinates, -0.5..0.5). Read-only: `class_version`, `definition_handle`, `definition_reactor_handle`. Scripted creation matches OCS's native WIPEOUT command, which also leaves the definition link unset. Host validation requires finite non-parallel `u`/`v` vectors, a positive `size`, brightness/contrast/fade within 0..=100, known flags, finite clip vertices, exactly 2 vertices for a rectangular clip and at least 3 for a polygonal one. `staged_python_wipeout_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: rectangular creation, a switch to a three-vertex polygonal inside-clip with a move in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Underlay | **Integration-tested / blocked at W by acadrust (DXF rotation units)** | Writable: `underlay_type` and `definition_handle` (both fixed by the definition: creation names an existing `UnderlayDefinition`, and a mismatched type or missing definition is rejected), `insertion_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `flags`, `contrast`, `fade`, `clip_boundary_vertices`, `clip_inverted`. The file path and page live on the definition object, which scripts cannot create or list yet; they reference one the drawing already owns. Host validation requires finite nonzero scales, a finite rotation, a nonzero normal, contrast/fade within 0..=100, known flags, finite clip vertices (none, or at least 2) and an existing definition of the same type. `staged_python_underlay_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle` with a fixture PDF definition) passes C/R/E/D/U/I/V/P and DWG W: creation, a placement/scale/rotation/contrast/fade/clip transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by canary assertions):** DXF stores the rotation in degrees but the reader returns it unconverted, so 0.5 rad reopens as 28.6 and each further DXF save-reopen compounds it (1641.4 after a second), silently corrupting the rotation. Not `Complete` until the reader converts degrees back to radians. Not covered: creating definitions from a file path, DWF/DGN fixtures. | -| Viewport | Layer-only | Requires layout and view-state fixture. | +| Viewport | **Integration-tested** | Writable: `center`, `width`, `height`, `status`, `view_center`, `view_direction`, `view_target`, `lens_length`, `front_clip_z`, `back_clip_z`, `view_height`, `twist_angle`, `custom_scale`, `frozen_layers` (layer handles), `render_mode`, `circle_sides`, `ucs_icon_visible`. Read-only: `id`, snap/grid settings, UCS fields, `clip_boundary_handle`, `style_sheet`, shade-plot, background, visual-style and sun handles, lighting, `ambient_color`, `elevation`. Reference rules: the owner must be a paper-space layout block (a model-space viewport is rejected) and every frozen-layer handle must be an existing layer. Host validation requires finite centers, positive width, height, view height, lens length and scale, a nonzero view direction, finite clip planes and twist, and at least 3 circle sides. `staged_python_viewport_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, paper-space owner supplied by the driver) passes C/R/E/D/U/V/W/P: creation in the paper-space block, a move/resize/view-height/twist/frozen-layer transaction, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. **Why not `Complete`:** the I gate lacks a canvas oracle because a paper-space viewport only draws on its layout tab, which the driver does not activate; viewport `id` uniqueness within a layout, clip boundary entities and view-to-scale synchronization are not handled by the host. | | ViewBorder | Layer-only | Requires linked view records. | | SectionSymbol | Layer-only | Requires section-view style and point records. | | Light | Layer-only | Requires position/target and photometric-state fixture. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 8e265b4a0..dad2dc77b 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 34 geometry converters. Other writable properties +canvas kinds outside its 35 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 34-kind generated schema it contains only handle, kind, and layer; +outside the 35-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 2223de060..9bba529fc 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 34 mapped kinds. +the 35 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 1ae0f7ed2..592f5f9e0 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -33,7 +33,8 @@ "Helix", "RasterImage", "Wipeout", - "Underlay" + "Underlay", + "Viewport" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -367,6 +368,41 @@ "definition_handle": { "required": true }, "insertion_point": { "required": true } } + }, + "Viewport": { + "fields": { + "id": { "read_only": true }, + "snap_base": { "read_only": true }, + "snap_spacing": { "read_only": true }, + "grid_spacing": { "read_only": true }, + "snap_angle": { "read_only": true }, + "clip_boundary_handle": { "read_only": true }, + "style_sheet": { "read_only": true }, + "ucs_at_origin": { "read_only": true }, + "ucs_per_viewport": { "read_only": true }, + "ucs_origin": { "read_only": true }, + "ucs_x_axis": { "read_only": true }, + "ucs_y_axis": { "read_only": true }, + "ucs_handle": { "read_only": true }, + "base_ucs_handle": { "read_only": true }, + "ucs_ortho_type": { "read_only": true }, + "elevation": { "read_only": true }, + "shade_plot_mode": { "read_only": true }, + "grid_flags": { "read_only": true }, + "grid_major": { "read_only": true }, + "background_handle": { "read_only": true }, + "shade_plot_handle": { "read_only": true }, + "visual_style_handle": { "read_only": true }, + "sun_handle": { "read_only": true }, + "default_lighting": { "read_only": true }, + "default_lighting_type": { "read_only": true }, + "brightness": { "read_only": true }, + "contrast": { "read_only": true }, + "ambient_color": { "read_only": true }, + "center": { "required": true }, + "width": { "required": true }, + "height": { "required": true } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 694281bb7..48bb52533 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4756,7 +4756,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 34); + assert_eq!(emitted.len(), 35); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 78803561e..6d7057d6a 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3195,6 +3195,11 @@ mod tests { let tmp = std::env::temp_dir().to_string_lossy().into_owned(); let mut create_script = case.create.replace("TMPDIR", &tmp); + let paper = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle.value()); + let layer0 = host.document().layers.iter().find(|l| l.name == "0").map(|l| l.handle.value()); + create_script = create_script + .replace("PAPERSPACE", &paper.unwrap_or_default().to_string()) + .replace("LAYER0", &layer0.unwrap_or_default().to_string()); if create_script.contains("DEFHANDLE") { // Scripts reference an underlay definition that the drawing owns. let handle = host.document_mut().allocate_handle(); @@ -3209,14 +3214,21 @@ mod tests { .unwrap_or_else(|| panic!("Python did not create the mesh: {}", last_output(&host))); let created = host.document().get_entity(handle).unwrap().clone(); assert_eq!((case.digest)(&created), case.expect_created); - assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); + // Viewports draw only on their paper-space layout tab, which this + // driver does not activate, so they have no model-tab wire oracle. + let has_wires = !matches!(created, EntityType::Viewport(_)); + if has_wires { + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); + } - run_script(&mut host, "edit", &case.edit.replace("HANDLE", &handle.value().to_string()).replace("TMPDIR", &tmp)); + run_script(&mut host, "edit", &case.edit.replace("HANDLE", &handle.value().to_string()).replace("TMPDIR", &tmp).replace("LAYER0", &layer0.unwrap_or_default().to_string())); let expected = host.document().get_entity(handle).unwrap().clone(); assert_eq!((case.digest)(&expected), case.expect_edited, "edit failed: {}", last_output(&host)); assert_eq!(expected.common(), created.common()); assert_eq!(host.selection(), vec![handle]); - assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty()); + if has_wires { + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty()); + } for (patch, message) in case.rejects { dispatch(&mut host, &format!( @@ -3586,6 +3598,45 @@ mod tests { }); } + #[test] + fn staged_python_viewport_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('Viewport', owner_handle=PAPERSPACE, center=P(100.0, 100.0, 0.0),\n", + " width=80.0, height=60.0, view_center=P(0.0, 0.0, 0.0), view_height=50.0)\n"), + edit: concat!( + "vp = doc.entities[HANDLE]\n", + "with doc.transaction('Edit viewport'):\n", + " vp.center = (120.0, 110.0, 0.0)\n", + " vp.width = 90.0\n", + " vp.view_height = 75.0\n", + " vp.twist_angle = 0.25\n", + " vp.frozen_layers = [LAYER0]\n", + "doc.selection = [vp]\n"), + rejects: &[ + ("'width':0.0", "greater than zero"), + ("'view_height':float('nan')", "greater than zero"), + ("'view_direction':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'frozen_layers':[999999]", "does not exist"), + ("'circle_sides':2", "at least 3"), + ("'id':7", "read-only"), + ("'clip_boundary_handle':5", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::Viewport(_)), + digest: |entity| match entity { + EntityType::Viewport(v) => format!("at{:.1},{:.1} {:.1}x{:.1} vh{:.1} tw{:.2} frozen{}", v.center.x, v.center.y, + v.width, v.height, v.view_height, v.twist_angle, v.frozen_layers.len()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Viewport(v) = entity { v.width = 95.0; }, + expect_created: "at100.0,100.0 80.0x60.0 vh50.0 tw0.00 frozen0", + expect_edited: "at120.0,110.0 90.0x60.0 vh75.0 tw0.25 frozen1", + expect_reedited: "at120.0,110.0 95.0x60.0 vh75.0 tw0.25 frozen1", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 21ce4b0e2b388ffeea272c452c7d857096d88c9c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 15:13:23 +0200 Subject: [PATCH 30/76] Map ViewBorder in the Python host model; record DXF kind blocker Bounds, scale and rotation are writable with viewport and scale reference rules. staged_python_view_border_lifecycle_over_real_ipc passes every gate in DWG; a DXF DRAWINGVIEW reopens as an opaque entity, pinned by a canary. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 13 +++++ crates/ocs_plugin_api/src/entity_coverage.rs | 47 ++++++++++++++++ docs/plugin-host-model-completion-plan.md | 9 ++-- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 12 ++++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 54 +++++++++++++++++++ 10 files changed, 137 insertions(+), 11 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 15b84f9f2..030115d49 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -795,6 +795,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("Wipeout", _) | ("Underlay", _) | ("Viewport", _) + | ("ViewBorder", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 978082943..533e50723 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -452,6 +452,16 @@ "render_mode", "circle_sides", "ucs_icon_visible" + ], + "ViewBorder": [ + "layer", + "min", + "max", + "center", + "scale", + "rotation_angle", + "active_viewport", + "scale_handle" ] }, "readable": { @@ -553,6 +563,9 @@ "brightness", "contrast", "ambient_color" + ], + "ViewBorder": [ + "version" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 6d44d4385..e20e03490 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -949,6 +949,32 @@ fn validate_viewport( Ok(()) } +/// ViewBorder: the frame of a drawing view, tied to a viewport and a scale. +#[cfg(feature = "host")] +fn validate_view_border( + old: Option<&crate::host::acadrust::entities::ViewBorder>, + new: &crate::host::acadrust::entities::ViewBorder, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + for (name, pair) in [("min", new.min), ("max", new.max), ("center", new.center)] { + if !pair[0].is_finite() || !pair[1].is_finite() { + return Err(format!("ViewBorder.{name} must be finite")); + } + } + if new.min[0] >= new.max[0] || new.min[1] >= new.max[1] { + return Err("ViewBorder.min must be below max on both axes".into()); + } + if !new.scale.is_finite() || new.scale <= 0.0 { + return Err("ViewBorder.scale must be finite and greater than zero".into()); + } + if !new.rotation_angle.is_finite() { + return Err("ViewBorder.rotation_angle must be finite".into()); + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -1053,6 +1079,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::Wipeout(value) => validate_wipeout(None, value)?, EntityType::Underlay(value) => validate_underlay(None, value)?, EntityType::Viewport(value) => validate_viewport(None, value)?, + EntityType::ViewBorder(value) => validate_view_border(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1418,6 +1445,7 @@ pub fn validate_entity_mutation( (EntityType::Wipeout(old), EntityType::Wipeout(new)) => validate_wipeout(Some(old), new)?, (EntityType::Underlay(old), EntityType::Underlay(new)) => validate_underlay(Some(old), new)?, (EntityType::Viewport(old), EntityType::Viewport(new)) => validate_viewport(Some(old), new)?, + (EntityType::ViewBorder(old), EntityType::ViewBorder(new)) => validate_view_border(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -1681,6 +1709,17 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + if let EntityType::ViewBorder(value) = entity { + if !matches!(document.get_entity(value.active_viewport), Some(EntityType::Viewport(_))) { + return Err(format!("ViewBorder viewport {:?} does not exist", value.active_viewport)); + } + if !matches!( + document.objects.get(&value.scale_handle), + Some(crate::host::acadrust::objects::ObjectType::Scale(_)) + ) { + return Err(format!("ViewBorder scale {:?} does not exist", value.scale_handle)); + } + } if let EntityType::Viewport(value) = entity { let owner = value.common.owner_handle; let in_paper_space = !owner.is_null() @@ -2144,6 +2183,7 @@ mod tests { | "Wipeout" | "Underlay" | "Viewport" + | "ViewBorder" ) { assert!( @@ -2459,6 +2499,13 @@ mod tests { "Viewport.render_mode".to_owned(), "Viewport.circle_sides".to_owned(), "Viewport.ucs_icon_visible".to_owned(), + "ViewBorder.min".to_owned(), + "ViewBorder.max".to_owned(), + "ViewBorder.center".to_owned(), + "ViewBorder.scale".to_owned(), + "ViewBorder.rotation_angle".to_owned(), + "ViewBorder.active_viewport".to_owned(), + "ViewBorder.scale_handle".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 752441150..fd7834de7 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **35 of 43** canvas kinds: 34 -are creatable and `Insert` remains update-only. This is **not** 35 completed +v7. The Python feature has explicit writes for **36 of 43** canvas kinds: 35 +are creatable and `Insert` remains update-only. This is **not** 36 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh and Wipeout pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix and Underlay are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 8 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay and ViewBorder are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 7 canvas kinds have only `layer` writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **ViewBorder** (queue item 14). Leader is done except for its W +Continue with **SectionSymbol** (queue item 15). Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -168,3 +168,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `Wipeout` (queue item 11) with rectangular and polygonal clip masks through the shared lifecycle driver; scripted creation matches OCS's native command. The queue now continues with `Underlay`. - Mapped `Underlay` against a fixture PDF `UnderlayDefinition`. All gates pass except DXF W: the reader does not convert the degree-valued rotation back to radians, and repeated DXF round trips compound the error (pinned by a canary). The queue now continues with `Viewport`. - Mapped `Viewport` with a paper-space owner rule and frozen-layer reference validation. Every gate except a paper-space canvas oracle passes through the shared driver; the kind stays integration-tested. The queue now continues with `ViewBorder`. +- Mapped `ViewBorder` with viewport and scale reference rules. All gates pass in DWG; DXF loading does not restore the typed entity (pinned by a canary). The queue now continues with `SectionSymbol`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index cd29a97ba..805a8a05c 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **35 of 43 canvas kinds have explicit geometry writes** (34 creatable, plus update-only `Insert`), **12 kinds are complete**, and 8 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **36 of 43 canvas kinds have explicit geometry writes** (35 creatable, plus update-only `Insert`), **12 kinds are complete**, and 7 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -41,7 +41,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Wipeout | **Complete** | Writable: `insertion_point`, `u_vector`, `v_vector` (the region's world extent), `size`, `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_mode`, `clip_type` and `clip_boundary_vertices` (normalized image coordinates, -0.5..0.5). Read-only: `class_version`, `definition_handle`, `definition_reactor_handle`. Scripted creation matches OCS's native WIPEOUT command, which also leaves the definition link unset. Host validation requires finite non-parallel `u`/`v` vectors, a positive `size`, brightness/contrast/fade within 0..=100, known flags, finite clip vertices, exactly 2 vertices for a rectangular clip and at least 3 for a polygonal one. `staged_python_wipeout_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: rectangular creation, a switch to a three-vertex polygonal inside-clip with a move in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Underlay | **Integration-tested / blocked at W by acadrust (DXF rotation units)** | Writable: `underlay_type` and `definition_handle` (both fixed by the definition: creation names an existing `UnderlayDefinition`, and a mismatched type or missing definition is rejected), `insertion_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `flags`, `contrast`, `fade`, `clip_boundary_vertices`, `clip_inverted`. The file path and page live on the definition object, which scripts cannot create or list yet; they reference one the drawing already owns. Host validation requires finite nonzero scales, a finite rotation, a nonzero normal, contrast/fade within 0..=100, known flags, finite clip vertices (none, or at least 2) and an existing definition of the same type. `staged_python_underlay_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle` with a fixture PDF definition) passes C/R/E/D/U/I/V/P and DWG W: creation, a placement/scale/rotation/contrast/fade/clip transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by canary assertions):** DXF stores the rotation in degrees but the reader returns it unconverted, so 0.5 rad reopens as 28.6 and each further DXF save-reopen compounds it (1641.4 after a second), silently corrupting the rotation. Not `Complete` until the reader converts degrees back to radians. Not covered: creating definitions from a file path, DWF/DGN fixtures. | | Viewport | **Integration-tested** | Writable: `center`, `width`, `height`, `status`, `view_center`, `view_direction`, `view_target`, `lens_length`, `front_clip_z`, `back_clip_z`, `view_height`, `twist_angle`, `custom_scale`, `frozen_layers` (layer handles), `render_mode`, `circle_sides`, `ucs_icon_visible`. Read-only: `id`, snap/grid settings, UCS fields, `clip_boundary_handle`, `style_sheet`, shade-plot, background, visual-style and sun handles, lighting, `ambient_color`, `elevation`. Reference rules: the owner must be a paper-space layout block (a model-space viewport is rejected) and every frozen-layer handle must be an existing layer. Host validation requires finite centers, positive width, height, view height, lens length and scale, a nonzero view direction, finite clip planes and twist, and at least 3 circle sides. `staged_python_viewport_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, paper-space owner supplied by the driver) passes C/R/E/D/U/V/W/P: creation in the paper-space block, a move/resize/view-height/twist/frozen-layer transaction, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. **Why not `Complete`:** the I gate lacks a canvas oracle because a paper-space viewport only draws on its layout tab, which the driver does not activate; viewport `id` uniqueness within a layout, clip boundary entities and view-to-scale synchronization are not handled by the host. | -| ViewBorder | Layer-only | Requires linked view records. | +| ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | | SectionSymbol | Layer-only | Requires section-view style and point records. | | Light | Layer-only | Requires position/target and photometric-state fixture. | | Region | Layer-only | Requires valid ACIS region payload. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index dad2dc77b..50ef30ea2 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 35 geometry converters. Other writable properties +canvas kinds outside its 36 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 35-kind generated schema it contains only handle, kind, and layer; +outside the 36-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 9bba529fc..6ba899e49 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 35 mapped kinds. +the 36 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 592f5f9e0..2851e1076 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -34,7 +34,8 @@ "RasterImage", "Wipeout", "Underlay", - "Viewport" + "Viewport", + "ViewBorder" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -403,6 +404,15 @@ "width": { "required": true }, "height": { "required": true } } + }, + "ViewBorder": { + "fields": { + "version": { "read_only": true }, + "min": { "required": true }, + "max": { "required": true }, + "active_viewport": { "required": true }, + "scale_handle": { "required": true } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 48bb52533..a6e8589ec 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4756,7 +4756,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 35); + assert_eq!(emitted.len(), 36); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 6d7057d6a..277ff0c00 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3197,6 +3197,20 @@ mod tests { let mut create_script = case.create.replace("TMPDIR", &tmp); let paper = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle.value()); let layer0 = host.document().layers.iter().find(|l| l.name == "0").map(|l| l.handle.value()); + if create_script.contains("VIEWPORTHANDLE") || create_script.contains("SCALEHANDLE") { + // A view border ties to an existing viewport and scale. + let owner = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle).unwrap(); + let mut viewport = acadrust::entities::Viewport::new(); + viewport.common.owner_handle = owner; + let viewport_handle = host.add_entity(EntityType::Viewport(viewport)); + let scale_handle = host.document_mut().allocate_handle(); + let mut scale = acadrust::objects::Scale::new("1:1", 1.0, 1.0); + scale.handle = scale_handle; + host.document_mut().objects.insert(scale_handle, acadrust::objects::ObjectType::Scale(scale)); + create_script = create_script + .replace("VIEWPORTHANDLE", &viewport_handle.value().to_string()) + .replace("SCALEHANDLE", &scale_handle.value().to_string()); + } create_script = create_script .replace("PAPERSPACE", &paper.unwrap_or_default().to_string()) .replace("LAYER0", &layer0.unwrap_or_default().to_string()); @@ -3637,6 +3651,46 @@ mod tests { }); } + #[test] + fn staged_python_view_border_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('ViewBorder', min=[0.0, 0.0], max=[40.0, 30.0], center=[20.0, 15.0], scale=1.0,\n", + " active_viewport=VIEWPORTHANDLE, scale_handle=SCALEHANDLE)\n"), + edit: concat!( + "vb = doc.entities[HANDLE]\n", + "with doc.transaction('Edit view border'):\n", + " vb.max = [60.0, 45.0]\n", + " vb.center = [30.0, 22.5]\n", + " vb.scale = 2.0\n", + " vb.rotation_angle = 0.3\n", + "doc.selection = [vb]\n"), + rejects: &[ + ("'max':[-1.0, 5.0]", "below max"), + ("'scale':0.0", "greater than zero"), + ("'rotation_angle':float('nan')", "finite"), + ("'center':[float('inf'), 0.0]", "finite"), + ("'active_viewport':999999", "does not exist"), + ("'scale_handle':999999", "does not exist"), + ("'version':3", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::ViewBorder(_)), + digest: |entity| match entity { + EntityType::ViewBorder(v) => format!("max{:.1},{:.1} c{:.1},{:.1} s{:.1} r{:.1}", v.max[0], v.max[1], + v.center[0], v.center[1], v.scale, v.rotation_angle), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::ViewBorder(v) = entity { v.scale = 3.0; }, + expect_created: "max40.0,30.0 c20.0,15.0 s1.0 r0.0", + expect_edited: "max60.0,45.0 c30.0,22.5 s2.0 r0.3", + expect_reedited: "max60.0,45.0 c30.0,22.5 s3.0 r0.3", + // BLOCKER: a DXF DRAWINGVIEW reopens as a different (opaque) entity + // kind, not as a ViewBorder; only DWG restores the typed record. + expect_edited_dxf: "wrong kind", + expect_reedited_dxf: "wrong kind", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 12b0823e762fa7fea446f181735fe80adf94dd1e Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 15:18:13 +0200 Subject: [PATCH 31/76] Map Light; record SectionSymbol as blocked Light placement, intensity, colour, attenuation and shadow fields are writable with host validation. staged_python_light_lifecycle_over_real_ipc passes every gate except DXF cast_shadows, pinned by a canary. SectionSymbol is recorded as blocked: it needs host-created style and view representation objects and its raw point counts have no documented derivation. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 26 +++++++ crates/ocs_plugin_api/src/entity_coverage.rs | 68 +++++++++++++++++++ docs/plugin-host-model-completion-plan.md | 9 +-- docs/plugin-host-model-coverage-ledger.md | 6 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 10 ++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 42 ++++++++++++ 10 files changed, 158 insertions(+), 12 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 030115d49..71f39498f 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -796,6 +796,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("Underlay", _) | ("Viewport", _) | ("ViewBorder", _) + | ("Light", _) | ( "MLine", "flags" diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 533e50723..3169a9874 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -462,6 +462,29 @@ "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" ] }, "readable": { @@ -566,6 +589,9 @@ ], "ViewBorder": [ "version" + ], + "Light": [ + "class_version" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index e20e03490..395008570 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -975,6 +975,51 @@ fn validate_view_border( Ok(()) } +/// Light: type, placement, intensity, attenuation and shadow settings. +#[cfg(feature = "host")] +fn validate_light( + old: Option<&crate::host::acadrust::entities::Light>, + new: &crate::host::acadrust::entities::Light, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + if new.name.trim().is_empty() { + return Err("Light.name is empty".into()); + } + if !(1..=3).contains(&new.light_type) { + return Err("Light.light_type must be 1 (distant), 2 (point) or 3 (spot)".into()); + } + finite_vector("Light.position", &new.position)?; + finite_vector("Light.target", &new.target)?; + if new.light_type != 2 && new.position == new.target { + return Err("a distant or spot Light needs a target different from its position".into()); + } + if !new.intensity.is_finite() || new.intensity < 0.0 { + return Err("Light.intensity must be finite and non-negative".into()); + } + for (name, value) in [ + ("attenuation_start_limit", new.attenuation_start_limit), + ("attenuation_end_limit", new.attenuation_end_limit), + ("hotspot_angle", new.hotspot_angle), + ("falloff_angle", new.falloff_angle), + ] { + if !value.is_finite() || value < 0.0 { + return Err(format!("Light.{name} must be finite and non-negative")); + } + } + if new.use_attenuation_limits && new.attenuation_start_limit > new.attenuation_end_limit { + return Err("Light attenuation start limit must not exceed the end limit".into()); + } + if new.light_type == 3 && new.hotspot_angle > new.falloff_angle { + return Err("Light.hotspot_angle must not exceed falloff_angle".into()); + } + if new.shadow_map_size < 0 { + return Err("Light.shadow_map_size must be non-negative".into()); + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -1080,6 +1125,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::Underlay(value) => validate_underlay(None, value)?, EntityType::Viewport(value) => validate_viewport(None, value)?, EntityType::ViewBorder(value) => validate_view_border(None, value)?, + EntityType::Light(value) => validate_light(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1446,6 +1492,7 @@ pub fn validate_entity_mutation( (EntityType::Underlay(old), EntityType::Underlay(new)) => validate_underlay(Some(old), new)?, (EntityType::Viewport(old), EntityType::Viewport(new)) => validate_viewport(Some(old), new)?, (EntityType::ViewBorder(old), EntityType::ViewBorder(new)) => validate_view_border(Some(old), new)?, + (EntityType::Light(old), EntityType::Light(new)) => validate_light(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -2184,6 +2231,7 @@ mod tests { | "Underlay" | "Viewport" | "ViewBorder" + | "Light" ) { assert!( @@ -2506,6 +2554,26 @@ mod tests { "ViewBorder.rotation_angle".to_owned(), "ViewBorder.active_viewport".to_owned(), "ViewBorder.scale_handle".to_owned(), + "Light.name".to_owned(), + "Light.light_type".to_owned(), + "Light.position".to_owned(), + "Light.target".to_owned(), + "Light.status".to_owned(), + "Light.light_color".to_owned(), + "Light.plot_glyph".to_owned(), + "Light.intensity".to_owned(), + "Light.attenuation_type".to_owned(), + "Light.use_attenuation_limits".to_owned(), + "Light.attenuation_start_limit".to_owned(), + "Light.attenuation_end_limit".to_owned(), + "Light.hotspot_angle".to_owned(), + "Light.falloff_angle".to_owned(), + "Light.cast_shadows".to_owned(), + "Light.shadow_type".to_owned(), + "Light.shadow_map_size".to_owned(), + "Light.shadow_map_softness".to_owned(), + "Light.photometric_mode".to_owned(), + "Light.photometric_data".to_owned(), ]) ); } diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index fd7834de7..d0a154975 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **36 of 43** canvas kinds: 35 -are creatable and `Insert` remains update-only. This is **not** 36 completed +v7. The Python feature has explicit writes for **37 of 43** canvas kinds: 36 +are creatable and `Insert` remains update-only. This is **not** 37 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh and Wipeout pass every completion gate. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay and ViewBorder are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 7 canvas kinds have only `layer` +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 6 canvas kinds have only `layer` (SectionSymbol is recorded as blocked) writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **SectionSymbol** (queue item 15). Leader is done except for its W +Continue with **Region** (queue item 17). SectionSymbol (item 15) is blocked; see the ledger. Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -169,3 +169,4 @@ exact queue above and update statuses with evidence after each increment. - Mapped `Underlay` against a fixture PDF `UnderlayDefinition`. All gates pass except DXF W: the reader does not convert the degree-valued rotation back to radians, and repeated DXF round trips compound the error (pinned by a canary). The queue now continues with `Viewport`. - Mapped `Viewport` with a paper-space owner rule and frozen-layer reference validation. Every gate except a paper-space canvas oracle passes through the shared driver; the kind stays integration-tested. The queue now continues with `ViewBorder`. - Mapped `ViewBorder` with viewport and scale reference rules. All gates pass in DWG; DXF loading does not restore the typed entity (pinned by a canary). The queue now continues with `SectionSymbol`. +- Recorded `SectionSymbol` (queue item 15) as **blocked**: it needs a section view style and view representation the host cannot create, and its raw point-count fields have no documented derivation. Mapped `Light` (item 16); every gate passes except DXF `cast_shadows`, which the reader drops (pinned by a canary). The queue now continues with `Region`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 805a8a05c..d3d935088 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **36 of 43 canvas kinds have explicit geometry writes** (35 creatable, plus update-only `Insert`), **12 kinds are complete**, and 7 remain layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only `Insert`), **12 kinds are complete**, and 6 remain layer-only (one of them, SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -42,8 +42,8 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Underlay | **Integration-tested / blocked at W by acadrust (DXF rotation units)** | Writable: `underlay_type` and `definition_handle` (both fixed by the definition: creation names an existing `UnderlayDefinition`, and a mismatched type or missing definition is rejected), `insertion_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `flags`, `contrast`, `fade`, `clip_boundary_vertices`, `clip_inverted`. The file path and page live on the definition object, which scripts cannot create or list yet; they reference one the drawing already owns. Host validation requires finite nonzero scales, a finite rotation, a nonzero normal, contrast/fade within 0..=100, known flags, finite clip vertices (none, or at least 2) and an existing definition of the same type. `staged_python_underlay_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle` with a fixture PDF definition) passes C/R/E/D/U/I/V/P and DWG W: creation, a placement/scale/rotation/contrast/fade/clip transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by canary assertions):** DXF stores the rotation in degrees but the reader returns it unconverted, so 0.5 rad reopens as 28.6 and each further DXF save-reopen compounds it (1641.4 after a second), silently corrupting the rotation. Not `Complete` until the reader converts degrees back to radians. Not covered: creating definitions from a file path, DWF/DGN fixtures. | | Viewport | **Integration-tested** | Writable: `center`, `width`, `height`, `status`, `view_center`, `view_direction`, `view_target`, `lens_length`, `front_clip_z`, `back_clip_z`, `view_height`, `twist_angle`, `custom_scale`, `frozen_layers` (layer handles), `render_mode`, `circle_sides`, `ucs_icon_visible`. Read-only: `id`, snap/grid settings, UCS fields, `clip_boundary_handle`, `style_sheet`, shade-plot, background, visual-style and sun handles, lighting, `ambient_color`, `elevation`. Reference rules: the owner must be a paper-space layout block (a model-space viewport is rejected) and every frozen-layer handle must be an existing layer. Host validation requires finite centers, positive width, height, view height, lens length and scale, a nonzero view direction, finite clip planes and twist, and at least 3 circle sides. `staged_python_viewport_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, paper-space owner supplied by the driver) passes C/R/E/D/U/V/W/P: creation in the paper-space block, a move/resize/view-height/twist/frozen-layer transaction, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. **Why not `Complete`:** the I gate lacks a canvas oracle because a paper-space viewport only draws on its layout tab, which the driver does not activate; viewport `id` uniqueness within a layout, clip boundary entities and view-to-scale synchronization are not handled by the host. | | ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | -| SectionSymbol | Layer-only | Requires section-view style and point records. | -| Light | Layer-only | Requires position/target and photometric-state fixture. | +| SectionSymbol | **Blocked** | Not mapped. A valid section mark needs a `SectionViewStyle` and a `ViewRep` view-representation object that a script cannot create through the host API, and the entity stores raw point counts and flag words (`raw_point_count_90`, `raw_flags_90`, `raw_point_record_count`, `raw_view_symbol_70`) whose relationship to `points` is undocumented in acadrust and is written back verbatim by both writers, so the host cannot keep them consistent after a scripted edit without guessing. Adding the kind needs (1) host-side creation or lookup of a section style and view representation, and (2) a confirmed derivation of the raw counts from a real DWG produced by another CAD application. The kind keeps raw snapshot access and layer edits. | +| Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. | | Region | Layer-only | Requires valid ACIS region payload. | | Body | Layer-only | Requires valid ACIS body payload. | | Solid3D | Layer-only | Requires valid ACIS solid payload. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 50ef30ea2..c6cccb12c 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 36 geometry converters. Other writable properties +canvas kinds outside its 37 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -91,7 +91,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 36-kind generated schema it contains only handle, kind, and layer; +outside the 37-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 6ba899e49..aac19fde3 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 36 mapped kinds. +the 37 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 2851e1076..6f0e0f87a 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -35,7 +35,8 @@ "Wipeout", "Underlay", "Viewport", - "ViewBorder" + "ViewBorder", + "Light" ], "manual_kinds": ["Dimension"], "base_fields": ["handle", "layer", "owner_handle"], @@ -413,6 +414,13 @@ "active_viewport": { "required": true }, "scale_handle": { "required": true } } + }, + "Light": { + "fields": { + "class_version": { "read_only": true }, + "position": { "required": true }, + "name": { "required": true } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index a6e8589ec..adb89de84 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4756,7 +4756,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 36); + assert_eq!(emitted.len(), 37); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 277ff0c00..675c73a33 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3691,6 +3691,48 @@ mod tests { }); } + #[test] + fn staged_python_light_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('Light', name='Key', light_type=3, position=P(10.0, 10.0, 10.0),\n", + " target=P(0.0, 0.0, 0.0), intensity=1.5, hotspot_angle=0.4, falloff_angle=0.8,\n", + " light_color={'kind': 'Rgb', 'value': {'r': 255, 'g': 240, 'b': 200}})\n"), + edit: concat!( + "lt = doc.entities[HANDLE]\n", + "with doc.transaction('Edit light'):\n", + " lt.intensity = 3.0\n", + " lt.target = (5.0, 5.0, 0.0)\n", + " lt.position = (12.0, 8.0, 10.0)\n", + " lt.cast_shadows = True\n", + "doc.selection = [lt]\n"), + rejects: &[ + ("'light_type':9", "must be 1 (distant)"), + ("'intensity':-1.0", "non-negative"), + ("'target':{'x':12.0,'y':8.0,'z':10.0}", "different from its position"), + ("'hotspot_angle':2.0", "must not exceed falloff_angle"), + ("'position':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'name':''", "empty"), + ("'class_version':2", "read-only"), + ("'light_color':{'kind':'Index','value':300}", "Color"), + ], + is_kind: |entity| matches!(entity, EntityType::Light(_)), + digest: |entity| match entity { + EntityType::Light(l) => format!("{} t{} i{:.1} pos{:.0},{:.0},{:.0} tgt{:.0},{:.0} sh{} {:?}", l.name, l.light_type, + l.intensity, l.position.x, l.position.y, l.position.z, l.target.x, l.target.y, l.cast_shadows, l.light_color), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Light(l) = entity { l.intensity = 4.0; }, + expect_created: "Key t3 i1.5 pos10,10,10 tgt0,0 shfalse Rgb { r: 255, g: 240, b: 200 }", + expect_edited: "Key t3 i3.0 pos12,8,10 tgt5,5 shtrue Rgb { r: 255, g: 240, b: 200 }", + expect_reedited: "Key t3 i4.0 pos12,8,10 tgt5,5 shtrue Rgb { r: 255, g: 240, b: 200 }", + // BLOCKER: DXF does not restore `cast_shadows` (it reopens false), + // like the other boolean groups the reader parses as integers. + expect_edited_dxf: "Key t3 i3.0 pos12,8,10 tgt5,5 shfalse Rgb { r: 255, g: 240, b: 200 }", + expect_reedited_dxf: "Key t3 i4.0 pos12,8,10 tgt5,5 shfalse Rgb { r: 255, g: 240, b: 200 }", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From a7287961e1ceebd6809c8186cfd276bbfed2c26c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 15:18:55 +0200 Subject: [PATCH 32/76] Record the remaining payload kinds as blocked Region, Body, Solid3D and Surface need a kernel-backed create/transform API that regenerates ACIS data; Ole2Frame needs a valid embedded-storage fixture. No opaque payload is rewritten. This completes the ordered queue. Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 5 +++-- docs/plugin-host-model-coverage-ledger.md | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index d0a154975..9b3bf3ebe 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -13,7 +13,7 @@ kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 6 canvas kinds have only `layer` (SectionSymbol is recorded as blocked) +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 6 canvas kinds (SectionSymbol, Region, Body, Solid3D, Surface, Ole2Frame) have only `layer` and are recorded as blocked writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -123,7 +123,7 @@ The last five may require new engine capabilities rather than only Python conver ## Handoff start point -Continue with **Region** (queue item 17). SectionSymbol (item 15) is blocked; see the ledger. Leader is done except for its W +The 21-kind queue is finished: items 1-14 and 16 are mapped and gated, and items 15 (SectionSymbol) and 17-21 (Region, Body, Solid3D, Surface, Ole2Frame) are recorded as **blocked** with concrete engine or fixture dependencies in the ledger. Leader is done except for its W gate; when the engine writes DXF group 340, reads group 213 and the DWG R2010+ text-size question is decided, flip the canary assertions in `staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. @@ -170,3 +170,4 @@ exact queue above and update statuses with evidence after each increment. - Mapped `Viewport` with a paper-space owner rule and frozen-layer reference validation. Every gate except a paper-space canvas oracle passes through the shared driver; the kind stays integration-tested. The queue now continues with `ViewBorder`. - Mapped `ViewBorder` with viewport and scale reference rules. All gates pass in DWG; DXF loading does not restore the typed entity (pinned by a canary). The queue now continues with `SectionSymbol`. - Recorded `SectionSymbol` (queue item 15) as **blocked**: it needs a section view style and view representation the host cannot create, and its raw point-count fields have no documented derivation. Mapped `Light` (item 16); every gate passes except DXF `cast_shadows`, which the reader drops (pinned by a canary). The queue now continues with `Region`. +- Recorded `Region`, `Body`, `Solid3D`, `Surface` (queue items 17-20) and `Ole2Frame` (item 21) as **blocked**. The ACIS kinds need a host-side kernel-backed create/transform API that regenerates the payload, wires and silhouettes; the OLE frame needs a valid embedded-storage fixture. No opaque payload is rewritten. With this the ordered queue is complete: 12 kinds are fully complete, Leader, Helix, Underlay, ViewBorder and Light are mapped but blocked at DWG/DXF persistence by acadrust gaps, RasterImage and Viewport lack an oracle or linkage, and six kinds are blocked. Next work is the engine fixes behind the canary assertions, the mapped-kind audit of the 17 pre-existing kinds, and merging `origin/main`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index d3d935088..a45e5e9e1 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -44,11 +44,11 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | | SectionSymbol | **Blocked** | Not mapped. A valid section mark needs a `SectionViewStyle` and a `ViewRep` view-representation object that a script cannot create through the host API, and the entity stores raw point counts and flag words (`raw_point_count_90`, `raw_flags_90`, `raw_point_record_count`, `raw_view_symbol_70`) whose relationship to `points` is undocumented in acadrust and is written back verbatim by both writers, so the host cannot keep them consistent after a scripted edit without guessing. Adding the kind needs (1) host-side creation or lookup of a section style and view representation, and (2) a confirmed derivation of the raw counts from a real DWG produced by another CAD application. The kind keeps raw snapshot access and layer edits. | | Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. | -| Region | Layer-only | Requires valid ACIS region payload. | -| Body | Layer-only | Requires valid ACIS body payload. | -| Solid3D | Layer-only | Requires valid ACIS solid payload. | -| Surface | Layer-only | Requires valid surface payload. | -| Ole2Frame | Layer-only | Requires valid embedded-object storage. | +| Region | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | +| Body | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | +| Solid3D | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | +| Surface | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | +| Ole2Frame | **Blocked** | Not mapped. A valid OLE frame carries a `StructuredStoragePayload` (a compound-file image of the embedded object) that a script cannot supply, so creation is impossible, and frame-geometry edits (`upper_left_corner`, `lower_right_corner`, `lock_aspect`) would be an update-only path that has no real embedded-object fixture to prove storage and aspect persistence through DWG and DXF. Adding it needs a fixture builder for a minimal valid OLE storage plus a proven round-trip of the payload; until then the kind keeps raw snapshot access and layer edits. | After each increment, update the row with test names, fixture paths, exact passing commands, the OCS commit hash, and any manual GUI result. Host and From bd43ed7e91318755bb76685ec4232a0c82cac7a4 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 15:44:26 +0200 Subject: [PATCH 33/76] Audit the 17 pre-existing mapped kinds; add shared basic-geometry validation Runs every first-generation kind through the real-runner lifecycle driver. The audit found Ellipse unvalidated, zero-height Text/MText and non-finite Arc angles and Point/Line thickness accepted, and no creation-time checks for most kinds. validate_basic_entity now covers them and refuses only problems an edit introduces. Twelve kinds pass every gate; Polyline, Polyline2D, Polyline3D and Spline carry pinned persistence findings, and Insert is update-only. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/entity_coverage.rs | 205 ++++++++ docs/plugin-host-model-completion-plan.md | 3 +- docs/plugin-host-model-coverage-ledger.md | 36 +- src/app/plugin_host.rs | 516 ++++++++++++++++++- 4 files changed, 737 insertions(+), 23 deletions(-) diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 395008570..54f9d1430 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1020,6 +1020,209 @@ fn validate_light( Ok(()) } +/// Every geometry problem of a first-generation mapped kind. Messages name +/// the field, so an edit can be compared against the entity it replaces. +#[cfg(feature = "host")] +fn basic_entity_errors(new: &crate::host::EntityType) -> Vec { + use crate::host::EntityType; + let mut errs: Vec = Vec::new(); + macro_rules! chk { + ($e:expr) => { + if let Err(error) = $e { + errs.push(error); + } + }; + } + let finite = |name: &str, value: f64| { + if value.is_finite() { + Ok(()) + } else { + Err(format!("{name} must be finite")) + } + }; + let positive = |name: &str, value: f64| { + if value.is_finite() && value > 0.0 { + Ok(()) + } else { + Err(format!("{name} must be finite and greater than zero")) + } + }; + match new { + EntityType::Point(v) => { + chk!(finite_vector("Point.location", &v.location)); + chk!(finite("Point.thickness", v.thickness)); + chk!(finite("Point.x_axis_angle", v.x_axis_angle)); + chk!(solid_normal("Point.normal", &v.normal)); + } + EntityType::Line(v) => { + chk!(finite_vector("Line.start", &v.start)); + chk!(finite_vector("Line.end", &v.end)); + chk!(finite("Line.thickness", v.thickness)); + chk!(solid_normal("Line.normal", &v.normal)); + } + EntityType::Circle(v) => { + chk!(finite_vector("Circle.center", &v.center)); + chk!(positive("Circle.radius", v.radius)); + chk!(finite("Circle.thickness", v.thickness)); + chk!(solid_normal("Circle.normal", &v.normal)); + } + EntityType::Arc(v) => { + chk!(finite_vector("Arc.center", &v.center)); + chk!(positive("Arc.radius", v.radius)); + chk!(finite("Arc.start_angle", v.start_angle)); + chk!(finite("Arc.end_angle", v.end_angle)); + chk!(finite("Arc.thickness", v.thickness)); + chk!(solid_normal("Arc.normal", &v.normal)); + } + EntityType::Ellipse(v) => { + chk!(finite_vector("Ellipse.center", &v.center)); + chk!(solid_normal("Ellipse.major_axis", &v.major_axis)); + if !v.minor_axis_ratio.is_finite() || v.minor_axis_ratio <= 0.0 || v.minor_axis_ratio > 1.0 { + errs.push("Ellipse.minor_axis_ratio must be within (0, 1]".into()); + } + chk!(finite("Ellipse.start_parameter", v.start_parameter)); + chk!(finite("Ellipse.end_parameter", v.end_parameter)); + chk!(solid_normal("Ellipse.normal", &v.normal)); + } + EntityType::Text(v) => { + chk!(finite_vector("Text.insertion_point", &v.insertion_point)); + if let Some(alignment) = &v.alignment_point { + chk!(finite_vector("Text.alignment_point", alignment)); + } + chk!(positive("Text.height", v.height)); + chk!(finite("Text.rotation", v.rotation)); + chk!(finite("Text.oblique_angle", v.oblique_angle)); + if !v.width_factor.is_finite() || v.width_factor <= 0.0 { + errs.push("Text.width_factor must be finite and greater than zero".into()); + } + chk!(finite("Text.thickness", v.thickness)); + chk!(solid_normal("Text.normal", &v.normal)); + } + EntityType::MText(v) => { + chk!(finite_vector("MText.insertion_point", &v.insertion_point)); + chk!(positive("MText.height", v.height)); + chk!(finite("MText.rotation", v.rotation)); + if !v.rectangle_width.is_finite() || v.rectangle_width < 0.0 { + errs.push("MText.rectangle_width must be finite and non-negative".into()); + } + if let Some(height) = v.rectangle_height { + chk!(finite("MText.rectangle_height", height)); + } + chk!(positive("MText.line_spacing_factor", v.line_spacing_factor)); + chk!(solid_normal("MText.normal", &v.normal)); + } + EntityType::LwPolyline(v) => { + if v.vertices.len() < 2 { + errs.push("LwPolyline.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + if !vertex.location.x.is_finite() || !vertex.location.y.is_finite() { + errs.push(format!("LwPolyline.vertices[{i}].location must be finite")); + } + if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() { + errs.push(format!("LwPolyline.vertices[{i}] has a non-finite bulge or width")); + } + } + if !v.constant_width.is_finite() || v.constant_width < 0.0 { + errs.push("LwPolyline.constant_width must be finite and non-negative".into()); + } + chk!(finite("LwPolyline.elevation", v.elevation)); + chk!(finite("LwPolyline.thickness", v.thickness)); + chk!(solid_normal("LwPolyline.normal", &v.normal)); + } + EntityType::Polyline2D(v) => { + if v.vertices.len() < 2 { + errs.push("Polyline2D.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + chk!(finite_vector(&format!("Polyline2D.vertices[{i}].location"), &vertex.location)); + if !vertex.bulge.is_finite() || !vertex.start_width.is_finite() || !vertex.end_width.is_finite() { + errs.push(format!("Polyline2D.vertices[{i}] has a non-finite bulge or width")); + } + } + for (name, value) in [("start_width", v.start_width), ("end_width", v.end_width), ("thickness", v.thickness), ("elevation", v.elevation)] { + chk!(finite(&format!("Polyline2D.{name}"), value)); + } + chk!(solid_normal("Polyline2D.normal", &v.normal)); + } + EntityType::Polyline(v) => { + if v.vertices.len() < 2 { + errs.push("Polyline.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + chk!(finite_vector(&format!("Polyline.vertices[{i}].location"), &vertex.location)); + } + } + EntityType::Polyline3D(v) => { + if v.vertices.len() < 2 { + errs.push("Polyline3D.vertices requires at least 2 vertices".into()); + } + for (i, vertex) in v.vertices.iter().enumerate() { + chk!(finite_vector(&format!("Polyline3D.vertices[{i}].position"), &vertex.position)); + } + for (name, value) in [("default_start_width", v.default_start_width), ("default_end_width", v.default_end_width), ("elevation", v.elevation)] { + chk!(finite(&format!("Polyline3D.{name}"), value)); + } + chk!(solid_normal("Polyline3D.normal", &v.normal)); + } + EntityType::Spline(v) => { + if v.degree < 1 { + errs.push("Spline.degree must be at least 1".into()); + } + let degree = v.degree as usize; + if v.control_points.len() <= degree { + errs.push("Spline needs more control points than its degree".into()); + } + for (i, point) in v.control_points.iter().enumerate() { + chk!(finite_vector(&format!("Spline.control_points[{i}]"), point)); + } + for (i, point) in v.fit_points.iter().enumerate() { + chk!(finite_vector(&format!("Spline.fit_points[{i}]"), point)); + } + if v.knots.len() != v.control_points.len() + degree + 1 { + errs.push(format!( + "Spline.knots must hold {} values for {} control points of degree {degree}", + v.control_points.len() + degree + 1, + v.control_points.len() + )); + } + if v.knots.iter().any(|k| !k.is_finite()) || v.knots.windows(2).any(|w| w[1] < w[0]) { + errs.push("Spline.knots must be finite and nondecreasing".into()); + } + if !v.weights.is_empty() { + if v.weights.len() != v.control_points.len() { + errs.push("Spline.weights must match the control points".into()); + } + if v.weights.iter().any(|w| !w.is_finite() || *w <= 0.0) { + errs.push("Spline.weights must be finite and greater than zero".into()); + } + } + chk!(solid_normal("Spline.normal", &v.normal)); + } + _ => {} + } + errs +} + +/// Creation must be fully valid. An edit is refused only for problems it +/// introduces: a legacy value that was already invalid and is untouched +/// keeps the entity editable. +#[cfg(feature = "host")] +fn validate_basic_entity( + old: Option<&crate::host::EntityType>, + new: &crate::host::EntityType, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + let errors = basic_entity_errors(new); + let existing = old.map(basic_entity_errors).unwrap_or_default(); + match errors.into_iter().find(|error| !existing.contains(error)) { + Some(error) => Err(error), + None => Ok(()), + } +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -1028,6 +1231,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() if entity.common().layer.trim().is_empty() { return Err("layer name is empty".into()); } + validate_basic_entity(None, entity)?; match entity { EntityType::Ray(value) => { finite_vector("Ray.base_point", &value.base_point)?; @@ -1283,6 +1487,7 @@ pub fn validate_entity_mutation( Ok(()) } }; + validate_basic_entity(Some(before), after)?; match (before, after) { (EntityType::Point(old), EntityType::Point(new)) => { if !same3(&old.location, &new.location) { diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 9b3bf3ebe..b4f8fff04 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -9,7 +9,7 @@ As of 20 September 2026, the working branch is `plugin/host-model-api` at [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is v7. The Python feature has explicit writes for **37 of 43** canvas kinds: 36 are creatable and `Insert` remains update-only. This is **not** 37 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh and Wipeout pass every completion gate. +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; the audited Polyline, Polyline2D, Polyline3D, Spline and Insert pass every gate that their format or update-only status allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current @@ -171,3 +171,4 @@ exact queue above and update statuses with evidence after each increment. - Mapped `ViewBorder` with viewport and scale reference rules. All gates pass in DWG; DXF loading does not restore the typed entity (pinned by a canary). The queue now continues with `SectionSymbol`. - Recorded `SectionSymbol` (queue item 15) as **blocked**: it needs a section view style and view representation the host cannot create, and its raw point-count fields have no documented derivation. Mapped `Light` (item 16); every gate passes except DXF `cast_shadows`, which the reader drops (pinned by a canary). The queue now continues with `Region`. - Recorded `Region`, `Body`, `Solid3D`, `Surface` (queue items 17-20) and `Ole2Frame` (item 21) as **blocked**. The ACIS kinds need a host-side kernel-backed create/transform API that regenerates the payload, wires and silhouettes; the OLE frame needs a valid embedded-storage fixture. No opaque payload is rewritten. With this the ordered queue is complete: 12 kinds are fully complete, Leader, Helix, Underlay, ViewBorder and Light are mapped but blocked at DWG/DXF persistence by acadrust gaps, RasterImage and Viewport lack an oracle or linkage, and six kinds are blocked. Next work is the engine fixes behind the canary assertions, the mapped-kind audit of the 17 pre-existing kinds, and merging `origin/main`. +- Audited the 17 pre-existing mapped kinds through the shared real-runner driver (`audit_python_*`). The audit found and fixed genuine validation gaps: Ellipse had none, Text and MText accepted a zero height, Arc accepted non-finite angles, Point and Line accepted infinite thickness, and most basic kinds had no creation-time validation. A shared `validate_basic_entity` now covers Point, Line, Circle, Arc, Ellipse, Text, MText, LwPolyline, Polyline, Polyline2D, Polyline3D and Spline, refusing only problems an edit introduces so already-invalid legacy values stay editable. Twelve of the 17 kinds now pass every gate (Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid, Face3D). Four expose persistence findings pinned by canaries (Polyline reopens as Polyline3D; Polyline2D reopens as LwPolyline in DXF; Polyline3D loses elevation in DWG; Spline loses weights in DXF), and Insert is update-only with all applicable gates passing. The driver gained per-format DWG expectations and a host-made block fixture for Insert. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index a45e5e9e1..c98c5d441 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,28 +1,28 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only `Insert`), **12 kinds are complete**, and 6 remain layer-only (one of them, SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only `Insert`), **24 kinds are complete**, and 6 remain layer-only (one of them, SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. | Kind | Baseline | Gate evidence / blocker | |---|---|---| -| Point | Mapped | Host transaction test now checks invalid batch rollback, grouped edit undo, and redo; creation/deletion history, real plugin execution, GUI, and DWG/DXF gates remain pending. | -| Line | Integration-tested | High-level document-model create/delete and transaction edit pass through the actual staged-plugin IPC runner; three undo entries, undo/redo, duplicate-handle and nonfinite-coordinate rejection, full typed-entity preservation, Line object-pick routing, and edited DWG/DXF reopen pass. A second editable property outside defining endpoint geometry still needs integration coverage before this row is complete. | -| Circle | Mapped | Full gate pending. | -| Arc | Mapped | Full gate pending. | -| Ellipse | Mapped | Existing focused DWG conversion test; full gate pending. | -| Polyline | Mapped | Full gate pending. | -| Polyline2D | Mapped | Full gate pending. | -| Polyline3D | Mapped | Full gate pending. | -| LwPolyline | Mapped | Full gate pending. | -| Spline | Mapped | Full gate pending. | -| Text | Mapped | Full gate pending. | -| MText | Mapped | Full gate pending. | -| Ray | Mapped | Focused DWG round trip; full gate pending. | -| XLine | Mapped | Focused DWG round trip; full gate pending. | -| Solid | Mapped | Focused DWG round trip; full gate pending. | -| Face3D | Mapped | Focused DWG round trip; full gate pending. | -| Insert | Update-only | Transform transaction and undo tested; Python creation, deletion, real IPC/GUI and DWG/DXF gates pending. | +| Point | **Complete** | Exercised: `location`, `thickness`. Rejects non-finite location, thickness and axis angle and a zero normal, which the audit found were previously accepted (thickness and normal) or unchecked at creation. `audit_python_point_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Line | **Complete** | Exercised: `end`, `thickness`. Existing endpoint, undo, selection and pick coverage plus the second-property edit the earlier row asked for. Rejects non-finite endpoints and thickness and a zero normal (thickness was previously accepted). `audit_python_line_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Circle | **Complete** | Exercised: `radius`, `center`. Rejects a non-positive or non-finite radius, non-finite center and thickness, and a zero normal. `audit_python_circle_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Arc | **Complete** | Exercised: `end_angle`, `radius`. Rejects a non-positive radius, non-finite angles (previously accepted), center and thickness, and a zero normal. `audit_python_arc_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Ellipse | **Complete** | Exercised: `minor_axis_ratio`, `end_parameter`. The audit found Ellipse had no validation at all; it now requires a nonzero major axis, a ratio within (0, 1], finite parameters and a nonzero normal. `audit_python_ellipse_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Polyline | **Integration-tested / kind changes on save** | Exercised: `vertices`, `closed`. The legacy `Polyline` (3-D vertices) passes create, edit, reject, undo and canvas gates, and its vertices and closed state survive, but both writers save it as a 3-D polyline, so it reopens from DWG and DXF as `Polyline3D`. Pinned by per-format canary expectations. Decide whether to keep `Polyline` scriptable or steer scripts to `Polyline3D`. `audit_python_polyline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Polyline2D | **Integration-tested / kind changes on DXF save** | Exercised: `vertices`, `closed`, `thickness`. Passes every gate in DWG. DXF reopens a 2-D polyline as an `LwPolyline` (vertices, closure and width survive, but the kind changes and `thickness` is not part of that record's digest here); pinned by a canary expectation. `audit_python_polyline2d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Polyline3D | **Integration-tested / blocked at W by the DWG format (elevation)** | Exercised: `vertices`, `elevation`. Passes every gate in DXF; DWG does not store a 3-D polyline's `elevation`, so it reopens as 0 (pinned by a canary). Vertices persist. `audit_python_polyline3d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| LwPolyline | **Complete** | Exercised: `vertices` (with a bulge), `is_closed`, `constant_width`. Rejects fewer than 2 vertices, non-finite locations, bulges and widths, a negative constant width and a zero normal. `audit_python_lwpolyline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Spline | **Integration-tested / blocked at W by acadrust (DXF weights)** | Exercised: `control_points`, `weights`. Validation covers degree, control-point count, knot count and order, weights and finite points. Passes every gate in DWG; the DXF reader does not restore `weights` (a rational spline reopens non-rational; pinned by a canary). `audit_python_spline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Text | **Complete** | Exercised: `text`, `rotation`, `height`. Rejects a non-positive height (previously accepted), a non-positive width factor, non-finite placement and angles, and a zero normal. Style and alignment fields are mapped but not individually exercised. `audit_python_text_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| MText | **Complete** | Exercised: `text`, `rectangle_width`, `rotation`. Rejects a non-positive height (previously accepted), a negative width, non-finite placement, a non-positive line-spacing factor and a zero normal. Attachment, columns and background fields are mapped but not individually exercised. `audit_python_mtext_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Ray | **Complete** | Exercised: `base_point`, `direction`. Unit-direction and finite-point validation already existed; the audit added the full DWG and DXF reopen and re-edit gate. `audit_python_ray_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| XLine | **Complete** | Exercised: `base_point`, `direction`. Same as Ray. `audit_python_xline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Solid | **Complete** | Exercised: `fourth_corner`, `thickness`. Finite corners, thickness and normal validation already existed; the audit added the full gate. `audit_python_solid_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Face3D | **Complete** | Exercised: `third_corner`, `invisible_edges`. Finite corners and known edge bits validation already existed; the audit added the full gate. `audit_python_face3d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Insert | **Update-only (all gates pass)** | Exercised: `insert_point`, `x_scale`, `y_scale`, `rotation`, `column_count`, `column_spacing`. `audit_python_insert_lifecycle_over_real_ipc` passes E/D/U/I/W/V/P with a host-made block and insert: a six-property transaction edit, canvas wires, rejection of a zero scale, non-finite rotation and point, a zero array count, a zero normal and a `block_name` change (read-only), an atomic rollback, DWG and DXF reopen with re-edit, and undo/redo. Not `Complete` because Python cannot create an Insert (creation and block-reference semantics are an open decision: permanent update-only, or a create path that names an existing block). | | Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | | Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | | AttributeDefinition | **Integration-tested / DXF blocker pending revalidation** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, rendered text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open:** acadrust `568a12c` dropped optional ATTDEF fields including width factor while reading DXF. Rerun the regression against the currently merged `7ea4247` revision and update this row with the result. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 675c73a33..a23cd5b43 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3164,6 +3164,9 @@ mod tests { /// same as DWG). A canary that fails once the engine round-trips it. expect_edited_dxf: &'static str, expect_reedited_dxf: &'static str, + /// Same idea for DWG (empty: same as the in-session digest). + expect_edited_dwg: &'static str, + expect_reedited_dwg: &'static str, } fn run_mesh_lifecycle(case: &MeshCase) { @@ -3195,6 +3198,28 @@ mod tests { let tmp = std::env::temp_dir().to_string_lossy().into_owned(); let mut create_script = case.create.replace("TMPDIR", &tmp); + // Insert is update-only: the host makes a block and one insert, and the + // "create" script only has to exist. + let update_only = create_script.contains("MAKEINSERT"); + if update_only { + let next = host.document().next_handle(); + let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2)); + let mut record = acadrust::tables::BlockRecord::new("AUDITBLK"); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = end_handle; + host.document_mut().block_records.add(record).unwrap(); + let mut block = acadrust::entities::Block::new("AUDITBLK", acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::Block(block)).unwrap(); + let mut end = acadrust::entities::BlockEnd::new(); + end.common.handle = end_handle; + end.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::BlockEnd(end)).unwrap(); + let insert = acadrust::entities::Insert::new("AUDITBLK", acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + host.add_entity(EntityType::Insert(insert)); + } let paper = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle.value()); let layer0 = host.document().layers.iter().find(|l| l.name == "0").map(|l| l.handle.value()); if create_script.contains("VIEWPORTHANDLE") || create_script.contains("SCALEHANDLE") { @@ -3262,6 +3287,8 @@ mod tests { for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { let expected_digest = if format == "DXF" && !case.expect_edited_dxf.is_empty() { case.expect_edited_dxf + } else if format == "DWG" && !case.expect_edited_dwg.is_empty() { + case.expect_edited_dwg } else { case.expect_edited }; @@ -3287,6 +3314,8 @@ mod tests { let reopened = crate::io::load_bytes(name, bytes).unwrap(); let expected_digest = if name.ends_with("dxf") && !case.expect_reedited_dxf.is_empty() { case.expect_reedited_dxf + } else if name.ends_with("dwg") && !case.expect_reedited_dwg.is_empty() { + case.expect_reedited_dwg } else { case.expect_reedited }; @@ -3304,14 +3333,16 @@ mod tests { drop(process); drop(host); app.finish_pending_history(0); - assert_eq!(app.tabs[0].history.undo_stack.len(), 3); + assert_eq!(app.tabs[0].history.undo_stack.len(), if update_only { 2 } else { 3 }); app.undo_steps(1); assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&expected)); app.undo_steps(1); assert_eq!(app.tabs[0].scene.document.get_entity(handle), Some(&created)); - app.undo_steps(1); - assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); - app.redo_steps(3); + if !update_only { + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); + } + app.redo_steps(if update_only { 2 } else { 3 }); assert!(app.tabs[0].scene.document.get_entity(handle).is_none()); } @@ -3350,6 +3381,8 @@ mod tests { expect_created: "3x3 v9 z0 d0/0 NoSmooth", expect_edited: "3x3 v9 z2.5 d6/6 Cubic", expect_reedited: "3x3 v9 z2.5 d6/6 Cubic", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3393,6 +3426,8 @@ mod tests { expect_created: "v4 f2 z0 last Some((1, 3, 4))", expect_edited: "v5 f3 z3 last Some((3, -4, 5))", expect_reedited: "v5 f3 z3 last Some((3, -4, 5))", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3435,6 +3470,8 @@ mod tests { expect_created: "v4 f1 e1 sub0 crease Some(1.5)", expect_edited: "v5 f2 e1 sub1 crease Some(1.5)", expect_reedited: "v5 f2 e1 sub1 crease Some(1.5)", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3474,6 +3511,8 @@ mod tests { expect_reedited: "r5 t6 h2 endz10.0 ccwfalse cptrue", // BLOCKER: the DXF reader parses boolean group 290 as an i16 and // never applies it, so handedness always reopens counter-clockwise. + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "r5 t5 h2 endz10.0 ccwtrue cptrue", expect_reedited_dxf: "r5 t6 h2 endz10.0 ccwtrue cptrue", }); @@ -3523,6 +3562,8 @@ mod tests { expect_created: "ocs_raster_test.png 10.0,20.0 u0.5 b50 c50 f0 clipfalse n2 8x4 deftrue", expect_edited: "ocs_raster_test.png 12.0,22.0 u1.0 b70 c40 f10 cliptrue n2 8x4 deftrue", expect_reedited: "ocs_raster_test.png 12.0,22.0 u1.0 b80 c40 f10 cliptrue n2 8x4 deftrue", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3562,6 +3603,8 @@ mod tests { expect_created: "Rectangular Outside n2 at0.0,0.0 u10.0 v6.0", expect_edited: "Polygonal Inside n3 at2.0,3.0 u10.0 v6.0", expect_reedited: "Polygonal Inside n3 at2.0,3.0 u12.0 v6.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3607,6 +3650,8 @@ mod tests { expect_reedited: "Pdf at8.0,9.0 s4.0 r0.5 c70 f20 clip2", // BLOCKER: DXF stores the rotation in degrees and the reader returns // it unconverted, so 0.5 rad reopens as 28.6 and a second save-reopen compounds it to 1641.4. + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "Pdf at8.0,9.0 s3.0 r28.6 c70 f20 clip2", expect_reedited_dxf: "Pdf at8.0,9.0 s4.0 r1641.4 c70 f20 clip2", }); @@ -3646,6 +3691,8 @@ mod tests { expect_created: "at100.0,100.0 80.0x60.0 vh50.0 tw0.00 frozen0", expect_edited: "at120.0,110.0 90.0x60.0 vh75.0 tw0.25 frozen1", expect_reedited: "at120.0,110.0 95.0x60.0 vh75.0 tw0.25 frozen1", + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "", expect_reedited_dxf: "", }); @@ -3686,6 +3733,8 @@ mod tests { expect_reedited: "max60.0,45.0 c30.0,22.5 s3.0 r0.3", // BLOCKER: a DXF DRAWINGVIEW reopens as a different (opaque) entity // kind, not as a ViewBorder; only DWG restores the typed record. + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "wrong kind", expect_reedited_dxf: "wrong kind", }); @@ -3728,11 +3777,470 @@ mod tests { expect_reedited: "Key t3 i4.0 pos12,8,10 tgt5,5 shtrue Rgb { r: 255, g: 240, b: 200 }", // BLOCKER: DXF does not restore `cast_shadows` (it reopens false), // like the other boolean groups the reader parses as integers. + expect_edited_dwg: "", + expect_reedited_dwg: "", expect_edited_dxf: "Key t3 i3.0 pos12,8,10 tgt5,5 shfalse Rgb { r: 255, g: 240, b: 200 }", expect_reedited_dxf: "Key t3 i4.0 pos12,8,10 tgt5,5 shfalse Rgb { r: 255, g: 240, b: 200 }", }); } + #[test] + fn audit_python_point_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Point', location=P(1.0, 2.0, 3.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.location = (4.0, 5.0, 6.0)\n", " e.thickness = 2.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'location':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'thickness':float('inf')", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Point(_)), + digest: |entity| match entity { + EntityType::Point(v) => format!("{:.1},{:.1},{:.1} th{:.1}", v.location.x, v.location.y, v.location.z, v.thickness), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Point(v) = entity { v.location.z = 9.0; }, + expect_created: "1.0,2.0,3.0 th0.0", + expect_edited: "4.0,5.0,6.0 th2.0", + expect_reedited: "4.0,5.0,9.0 th2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_line_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Line', start=P(0.0, 0.0, 0.0), end=P(10.0, 0.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.end = (10.0, 5.0, 0.0)\n", " e.thickness = 1.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'start':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'thickness':float('inf')", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Line(_)), + digest: |entity| match entity { + EntityType::Line(v) => format!("{:.1},{:.1} th{:.1}", v.end.x, v.end.y, v.thickness), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Line(v) = entity { v.start.x = 1.0; }, + expect_created: "10.0,0.0 th0.0", + expect_edited: "10.0,5.0 th1.5", + expect_reedited: "10.0,5.0 th1.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_circle_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Circle', center=P(0.0, 0.0, 0.0), radius=5.0)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.radius = 7.0\n", " e.center = (1.0, 1.0, 0.0)\n", "doc.selection = [e]\n"), + rejects: &[ + ("'radius':0.0", "radius"), + ("'radius':-3.0", "radius"), + ("'center':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Circle(_)), + digest: |entity| match entity { + EntityType::Circle(v) => format!("{:.1},{:.1} r{:.1}", v.center.x, v.center.y, v.radius), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Circle(v) = entity { v.radius = 8.0; }, + expect_created: "0.0,0.0 r5.0", + expect_edited: "1.0,1.0 r7.0", + expect_reedited: "1.0,1.0 r8.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_arc_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Arc', center=P(0.0, 0.0, 0.0), radius=5.0, start_angle=0.0, end_angle=1.5)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.end_angle = 2.0\n", " e.radius = 6.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'radius':0.0", "radius"), + ("'end_angle':float('nan')", "finite"), + ("'center':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Arc(_)), + digest: |entity| match entity { + EntityType::Arc(v) => format!("r{:.1} {:.1}..{:.1}", v.radius, v.start_angle, v.end_angle), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Arc(v) = entity { v.start_angle = 0.5; }, + expect_created: "r5.0 0.0..1.5", + expect_edited: "r6.0 0.0..2.0", + expect_reedited: "r6.0 0.5..2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_ellipse_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Ellipse', center=P(0.0, 0.0, 0.0), major_axis=P(10.0, 0.0, 0.0), minor_axis_ratio=0.5)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.minor_axis_ratio = 0.25\n", " e.end_parameter = 3.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'minor_axis_ratio':0.0", "ratio"), + ("'minor_axis_ratio':2.0", "ratio"), + ("'major_axis':{'x':0.0,'y':0.0,'z':0.0}", "axis"), + ], + is_kind: |entity| matches!(entity, EntityType::Ellipse(_)), + digest: |entity| match entity { + EntityType::Ellipse(v) => format!("q{:.2} ..{:.1}", v.minor_axis_ratio, v.end_parameter), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Ellipse(v) = entity { v.minor_axis_ratio = 0.75; }, + expect_created: "q0.50 ..6.3", + expect_edited: "q0.25 ..3.0", + expect_reedited: "q0.75 ..3.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_ray_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Ray', base_point=P(0.0, 0.0, 0.0), direction=P(1.0, 0.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.base_point = (2.0, 2.0, 0.0)\n", " e.direction = (0.0, 1.0, 0.0)\n", "doc.selection = [e]\n"), + rejects: &[ + ("'direction':{'x':2.0,'y':0.0,'z':0.0}", "unit"), + ("'base_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Ray(_)), + digest: |entity| match entity { + EntityType::Ray(v) => format!("{:.1},{:.1} d{:.1},{:.1}", v.base_point.x, v.base_point.y, v.direction.x, v.direction.y), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Ray(v) = entity { v.base_point.x = 3.0; }, + expect_created: "0.0,0.0 d1.0,0.0", + expect_edited: "2.0,2.0 d0.0,1.0", + expect_reedited: "3.0,2.0 d0.0,1.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_xline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('XLine', base_point=P(0.0, 0.0, 0.0), direction=P(1.0, 0.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.base_point = (2.0, 2.0, 0.0)\n", " e.direction = (0.0, 1.0, 0.0)\n", "doc.selection = [e]\n"), + rejects: &[ + ("'direction':{'x':2.0,'y':0.0,'z':0.0}", "unit"), + ("'base_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::XLine(_)), + digest: |entity| match entity { + EntityType::XLine(v) => format!("{:.1},{:.1} d{:.1},{:.1}", v.base_point.x, v.base_point.y, v.direction.x, v.direction.y), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::XLine(v) = entity { v.base_point.x = 3.0; }, + expect_created: "0.0,0.0 d1.0,0.0", + expect_edited: "2.0,2.0 d0.0,1.0", + expect_reedited: "3.0,2.0 d0.0,1.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_solid_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Solid', first_corner=P(0.0, 0.0, 0.0), second_corner=P(4.0, 0.0, 0.0), third_corner=P(0.0, 4.0, 0.0), fourth_corner=P(4.0, 4.0, 0.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.fourth_corner = (5.0, 5.0, 0.0)\n", " e.thickness = 2.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'first_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'thickness':float('inf')", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Solid(_)), + digest: |entity| match entity { + EntityType::Solid(v) => format!("{:.1},{:.1} th{:.1}", v.fourth_corner.x, v.fourth_corner.y, v.thickness), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Solid(v) = entity { v.first_corner.x = 1.0; }, + expect_created: "4.0,4.0 th0.0", + expect_edited: "5.0,5.0 th2.0", + expect_reedited: "5.0,5.0 th2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_face3d_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Face3D', first_corner=P(0.0, 0.0, 0.0), second_corner=P(4.0, 0.0, 0.0), third_corner=P(4.0, 4.0, 1.0), fourth_corner=P(0.0, 4.0, 1.0))", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.third_corner = (4.0, 4.0, 2.0)\n", " e.invisible_edges = 3\n", "doc.selection = [e]\n"), + rejects: &[ + ("'first_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'invisible_edges':64", "bits"), + ], + is_kind: |entity| matches!(entity, EntityType::Face3D(_)), + digest: |entity| match entity { + EntityType::Face3D(v) => format!("z{:.1} inv{}", v.third_corner.z, v.invisible_edges.bits()), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Face3D(v) = entity { v.first_corner.x = 1.0; }, + expect_created: "z1.0 inv0", + expect_edited: "z2.0 inv3", + expect_reedited: "z2.0 inv3", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_text_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('Text', text='Hello', insertion=P(1.0, 2.0, 0.0), height=2.5)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.text = 'World'\n", " e.rotation = 0.5\n", " e.height = 3.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'height':0.0", "height"), + ("'height':-1.0", "height"), + ("'insertion':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Text(_)), + digest: |entity| match entity { + EntityType::Text(v) => format!("{} h{:.1} r{:.1}", v.value, v.height, v.rotation), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Text(v) = entity { v.height = 4.0; }, + expect_created: "Hello h2.5 r0.0", + expect_edited: "World h3.0 r0.5", + expect_reedited: "World h4.0 r0.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_mtext_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "doc.create_entity('MText', text='Line one', insertion=P(1.0, 2.0, 0.0), height=2.5, rectangle_width=30.0)", + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.text = 'Line two'\n", " e.rectangle_width = 40.0\n", " e.rotation = 0.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'height':0.0", "height"), + ("'rectangle_width':-1.0", "width"), + ("'insertion':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::MText(_)), + digest: |entity| match entity { + EntityType::MText(v) => format!("{} w{:.1} r{:.1}", v.value, v.rectangle_width, v.rotation), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::MText(v) = entity { v.rectangle_width = 50.0; }, + expect_created: "Line one w30.0 r0.0", + expect_edited: "Line two w40.0 r0.5", + expect_reedited: "Line two w50.0 r0.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_lwpolyline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('LwPolyline', vertices=[{'location': {'x': 0.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 5.0}}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 0.0}}, {'location': {'x': 10.0, 'y': 8.0}, 'bulge': 0.5}, {'location': {'x': 0.0, 'y': 8.0}}]\n", " e.is_closed = True\n", " e.constant_width = 0.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'location':{'x':0.0,'y':0.0}}]", "at least 2"), + ("'constant_width':-1.0", "non-negative"), + ("'vertices':[{'location':{'x':float('nan'),'y':0.0}},{'location':{'x':1.0,'y':0.0}}]", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::LwPolyline(_)), + digest: |entity| match entity { + EntityType::LwPolyline(v) => format!("n{} closed{} w{:.1} bulge{:.1}", v.vertices.len(), v.is_closed, v.constant_width, v.vertices.get(2).map_or(0.0, |x| x.bulge)), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::LwPolyline(v) = entity { v.constant_width = 1.0; }, + expect_created: "n3 closedfalse w0.0 bulge0.0", + expect_edited: "n4 closedtrue w0.5 bulge0.5", + expect_reedited: "n4 closedtrue w1.0 bulge0.5", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_polyline2d_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('Polyline2D', vertices=[{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 5.0, 'z': 0.0}}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 8.0, 'z': 0.0}}, {'location': {'x': 0.0, 'y': 8.0, 'z': 0.0}}]\n", " e.closed = True\n", " e.thickness = 1.0\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'thickness':float('inf')", "finite"), + ("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}},{'location':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Polyline2D(_)), + digest: |entity| match entity { + EntityType::Polyline2D(v) => format!("n{} closed{} th{:.1}", v.vertices.len(), v.flags.is_closed(), v.thickness), + other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()), + }, + reedit: |entity| if let EntityType::Polyline2D(v) = entity { v.thickness = 2.0; }, + expect_created: "n3 closedfalse th0.0", + expect_edited: "n4 closedtrue th1.0", + expect_reedited: "n4 closedtrue th2.0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "wrong kind LwPolyline", + expect_reedited_dxf: "wrong kind LwPolyline", + }); + } + + #[test] + fn audit_python_polyline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('Polyline', vertices=[{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'location': {'x': 10.0, 'y': 5.0, 'z': 4.0}}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'location': {'x': 10.0, 'y': 8.0, 'z': 6.0}}, {'location': {'x': 0.0, 'y': 8.0, 'z': 6.0}}]\n", " e.closed = True\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'vertices':[{'location':{'x':float('nan'),'y':0.0,'z':0.0}},{'location':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ], + is_kind: |entity| matches!(entity, EntityType::Polyline(_)), + digest: |entity| match entity { + EntityType::Polyline(v) => format!("n{} closed{} lastz{:.1}", v.vertices.len(), v.flags.is_closed(), v.vertices.last().map_or(0.0, |x| x.location.z)), + other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()), + }, + reedit: |entity| if let EntityType::Polyline(v) = entity { v.vertices[0].location.z = 1.0; }, + expect_created: "n3 closedfalse lastz4.0", + expect_edited: "n4 closedtrue lastz6.0", + expect_reedited: "n4 closedtrue lastz6.0", + expect_edited_dwg: "wrong kind Polyline3D", + expect_reedited_dwg: "wrong kind Polyline3D", + expect_edited_dxf: "wrong kind Polyline3D", + expect_reedited_dxf: "wrong kind Polyline3D", + }); + } + + #[test] + fn audit_python_polyline3d_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('Polyline3D', vertices=[{'position': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'position': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'position': {'x': 10.0, 'y': 5.0, 'z': 4.0}}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'position': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'position': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'position': {'x': 10.0, 'y': 8.0, 'z': 6.0}}, {'position': {'x': 0.0, 'y': 8.0, 'z': 6.0}}]\n", " e.elevation = 1.5\n", "doc.selection = [e]\n"), + rejects: &[ + ("'vertices':[{'position':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), + ("'elevation':float('inf')", "finite"), + ("'vertices':[{'position':{'x':float('nan'),'y':0.0,'z':0.0}},{'position':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Polyline3D(_)), + digest: |entity| match entity { + EntityType::Polyline3D(v) => format!("n{} el{:.1} lastz{:.1}", v.vertices.len(), v.elevation, v.vertices.last().map_or(0.0, |x| x.position.z)), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Polyline3D(v) = entity { v.elevation = 2.5; }, + expect_created: "n3 el0.0 lastz4.0", + expect_edited: "n4 el1.5 lastz6.0", + expect_reedited: "n4 el2.5 lastz6.0", + expect_edited_dwg: "n4 el0.0 lastz6.0", + expect_reedited_dwg: "n4 el0.0 lastz6.0", + expect_edited_dxf: "", + expect_reedited_dxf: "", + }); + } + + #[test] + fn audit_python_spline_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!("doc.create_entity('Spline', degree=2, knots=[0.0, 0.0, 0.0, 1.0, 1.0, 1.0], control_points=[{'x': 0.0, 'y': 0.0, 'z': 0.0}, {'x': 5.0, 'y': 5.0, 'z': 0.0}, {'x': 10.0, 'y': 0.0, 'z': 0.0}])\n"), + edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.control_points = [{'x': 0.0, 'y': 0.0, 'z': 0.0}, {'x': 5.0, 'y': 8.0, 'z': 0.0}, {'x': 10.0, 'y': 0.0, 'z': 0.0}]\n", " e.weights = [1.0, 2.0, 1.0]\n", "doc.selection = [e]\n"), + rejects: &[ + ("'degree':0", "at least 1"), + ("'knots':[0.0,1.0]", "must hold"), + ("'weights':[1.0,0.0,1.0]", "greater than zero"), + ("'weights':[1.0]", "match"), + ("'control_points':[{'x':float('nan'),'y':0.0,'z':0.0},{'x':1.0,'y':0.0,'z':0.0},{'x':2.0,'y':0.0,'z':0.0}]", "finite"), + ("'knots':[0.0,0.0,1.0,0.5,1.0,1.0]", "nondecreasing"), + ], + is_kind: |entity| matches!(entity, EntityType::Spline(_)), + digest: |entity| match entity { + EntityType::Spline(v) => format!("deg{} cp{} y{:.1} w{:?}", v.degree, v.control_points.len(), v.control_points[1].y, v.weights), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Spline(v) = entity { v.control_points[1].y = 9.0; }, + expect_created: "deg2 cp3 y5.0 w[]", + expect_edited: "deg2 cp3 y8.0 w[1.0, 2.0, 1.0]", + expect_reedited: "deg2 cp3 y9.0 w[1.0, 2.0, 1.0]", + expect_edited_dwg: "", + expect_reedited_dwg: "", + expect_edited_dxf: "deg2 cp3 y8.0 w[]", + expect_reedited_dxf: "deg2 cp3 y9.0 w[]", + }); + } + + #[test] + fn audit_python_insert_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "# MAKEINSERT\n", + edit: concat!( + "e = doc.entities[HANDLE]\n", + "with doc.transaction('Edit'):\n", + " e.insert_point = (4.0, 5.0, 0.0)\n", + " e.x_scale = 2.0\n", + " e.y_scale = 3.0\n", + " e.rotation = 0.5\n", + " e.column_count = 2\n", + " e.column_spacing = 10.0\n", + "doc.selection = [e]\n"), + rejects: &[ + ("'x_scale':0.0", "nonzero"), + ("'rotation':float('nan')", "finite"), + ("'column_count':0", "greater than zero"), + ("'insert_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'block_name':'OTHER'", "read-only"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ], + is_kind: |entity| matches!(entity, EntityType::Insert(_)), + digest: |entity| match entity { + EntityType::Insert(v) => format!("{} at{:.1},{:.1} s{:.1},{:.1} r{:.1} cols{}x{:.1}", v.block_name, v.insert_point.x, + v.insert_point.y, v.x_scale(), v.y_scale(), v.rotation, v.column_count, v.column_spacing), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Insert(v) = entity { v.rotation = 1.0; }, + expect_created: "AUDITBLK at1.0,2.0 s1.0,1.0 r0.0 cols1x0.0", + expect_edited: "AUDITBLK at4.0,5.0 s2.0,3.0 r0.5 cols2x10.0", + expect_reedited: "AUDITBLK at4.0,5.0 s2.0,3.0 r1.0 cols2x10.0", + expect_edited_dxf: "", + expect_reedited_dxf: "", + expect_edited_dwg: "", + expect_reedited_dwg: "", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From cae188dd083180ac256f18e6decace7726b51285 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 15:48:42 +0200 Subject: [PATCH 34/76] Audit AttributeDefinition through both formats; pin the DXF reader gap Compares every writable ATTDEF field through DWG and DXF. DWG round-trips the complete state; acadrust's DXF reader handles only eight group codes, so alignment, width factor, oblique angle, attribute flags, field length, generation flags, lock and text style reopen as defaults. Pinned by canary expectations and recorded in the ledger. Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 1 + docs/plugin-host-model-coverage-ledger.md | 2 +- src/app/plugin_host.rs | 71 ++++++++++++++++++++++- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index b4f8fff04..9362f43e5 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -172,3 +172,4 @@ exact queue above and update statuses with evidence after each increment. - Recorded `SectionSymbol` (queue item 15) as **blocked**: it needs a section view style and view representation the host cannot create, and its raw point-count fields have no documented derivation. Mapped `Light` (item 16); every gate passes except DXF `cast_shadows`, which the reader drops (pinned by a canary). The queue now continues with `Region`. - Recorded `Region`, `Body`, `Solid3D`, `Surface` (queue items 17-20) and `Ole2Frame` (item 21) as **blocked**. The ACIS kinds need a host-side kernel-backed create/transform API that regenerates the payload, wires and silhouettes; the OLE frame needs a valid embedded-storage fixture. No opaque payload is rewritten. With this the ordered queue is complete: 12 kinds are fully complete, Leader, Helix, Underlay, ViewBorder and Light are mapped but blocked at DWG/DXF persistence by acadrust gaps, RasterImage and Viewport lack an oracle or linkage, and six kinds are blocked. Next work is the engine fixes behind the canary assertions, the mapped-kind audit of the 17 pre-existing kinds, and merging `origin/main`. - Audited the 17 pre-existing mapped kinds through the shared real-runner driver (`audit_python_*`). The audit found and fixed genuine validation gaps: Ellipse had none, Text and MText accepted a zero height, Arc accepted non-finite angles, Point and Line accepted infinite thickness, and most basic kinds had no creation-time validation. A shared `validate_basic_entity` now covers Point, Line, Circle, Arc, Ellipse, Text, MText, LwPolyline, Polyline, Polyline2D, Polyline3D and Spline, refusing only problems an edit introduces so already-invalid legacy values stay editable. Twelve of the 17 kinds now pass every gate (Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid, Face3D). Four expose persistence findings pinned by canaries (Polyline reopens as Polyline3D; Polyline2D reopens as LwPolyline in DXF; Polyline3D loses elevation in DWG; Spline loses weights in DXF), and Insert is update-only with all applicable gates passing. The driver gained per-format DWG expectations and a host-made block fixture for Insert. +- Audited `AttributeDefinition` against acadrust `5b682ed` with a full-field comparison through both formats. Everything passes in DWG. DXF keeps only tag, prompt, value, insertion point, height and rotation because the reader handles eight group codes; alignment, width factor, oblique angle, attribute flags, field length, generation flags, lock and text style reopen as defaults. The kind stays integration-tested and the gap is pinned by canary expectations; the fix belongs in cadcodec's `read_attdef`. The shared driver's block fixture now also serves `MAKEBLOCK` scripts. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index c98c5d441..be6a96235 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -25,7 +25,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Insert | **Update-only (all gates pass)** | Exercised: `insert_point`, `x_scale`, `y_scale`, `rotation`, `column_count`, `column_spacing`. `audit_python_insert_lifecycle_over_real_ipc` passes E/D/U/I/W/V/P with a host-made block and insert: a six-property transaction edit, canvas wires, rejection of a zero scale, non-finite rotation and point, a zero array count, a zero normal and a `block_name` change (read-only), an atomic rollback, DWG and DXF reopen with re-edit, and undo/redo. Not `Complete` because Python cannot create an Insert (creation and block-reference semantics are an open decision: permanent update-only, or a create path that names an existing block). | | Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | | Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | -| AttributeDefinition | **Integration-tested / DXF blocker pending revalidation** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P with a real block record, Block/BlockEnd markers and linked Insert: high-level creation inside the block, readback, grouped placement/default/rotation/width edits, selection, rendered text, atomic tag/height/style/owner rejection, deletion persistence, and three-step undo/redo. DWG reopens with the full edited state. DXF reopens identity, ownership, tag, prompt, value, placement, height and rotation, and OCS no longer double-converts ATTDEF/ATTRIB rotation. **W remains open:** acadrust `568a12c` dropped optional ATTDEF fields including width factor while reading DXF. Rerun the regression against the currently merged `7ea4247` revision and update this row with the result. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | +| AttributeDefinition | **Integration-tested / blocked at W by acadrust (DXF ATTDEF reader)** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. Two staged tests cover it. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes with a real block record, Block/BlockEnd markers and a linked Insert. The 2026-09-20 audit `audit_python_attribute_definition_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, host-made block) compares **every writable field** through both formats: creation with non-default tag, prompt, value, placement, alignment point, height, rotation, width factor, oblique angle, alignments, all attribute flags, field length, generation flags and lock; a nine-property transaction edit; canvas wires; eight rejection cases (whitespace tag, non-positive height and width factor, missing style, owner change, zero normal, non-finite rotation, unknown alignment) plus an atomic rollback; DWG reopen with the **complete** edited state and re-edit; deletion; and three-step undo/redo. **W blocker, revalidated on acadrust `5b682ed`:** the DXF writer emits every ATTDEF group, but the reader handles only groups 1, 2, 3, 10, 40, 50, 280 and 101, so a DXF reopen keeps tag, prompt, value, insertion point, height and rotation and resets the alignment point, both alignments, width factor, oblique angle, all attribute flags (invisible, constant, verify, preset), field length, generation flags, lock and text style to defaults. This is now pinned by canary expectations for both the edit and the re-edit. Not `Complete` until the reader is extended; the fix belongs in cadcodec's `read_attdef`. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | | AttributeEntity | **Complete** | OCS commit `cf2a9235` and adapter commit `9c6b5c3` make nested ATTRIB records addressable by handle while retaining canonical storage in `Insert.attributes`. Host validation binds tag to a definition in the Insert's block, validates placement/text geometry and style references, keeps owner and definition handles read-only, and preserves embedded MTEXT. `staged_python_attribute_entity_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a real block, ATTDEF and Insert: high-level create/read/transaction edit/delete, parent selection, rendered text, atomic invalid tag/height/owner rejection, edited and deleted DWG/DXF reopen, and three-step undo/redo. `nested_insert_attribute_crud_uses_parent_storage_and_undo` additionally proves the child never becomes an orphan flat entity in the host document. | | Hatch | **Complete** | OCS commit `98b56ef6` and adapter commit `a9b5de8` expose solid and patterned Hatches with elevation/normal, complete pattern definitions, pattern controls, style, association state, boundary paths and seed points. Nested tagged boundary edges use the reusable `{kind, value}` adapter form. Host validation requires closed finite loops, validates line/arc/ellipse/polyline/spline payloads and stored pattern lines, resolves associative boundary handles, and preserves gradient and MPOLYGON payloads as explicitly unsupported. `staged_python_hatch_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P: Python solid creation, patterned boundary edit, live render-model pattern/boundary checks, selection, atomic invalid scale/association/open-loop/unmapped-property rejection, edited and deleted DWG/DXF reopen, post-reopen edits, and three-step undo/redo. | | Leader | **Integration-tested / blocked at W by acadrust** | Fields mapped writable: `vertices` (≥2 finite points, not all coincident), `annotation_handle`, `creation_type`, `path_type`, `dimension_style`, `arrow_enabled`, `hookline_enabled`, `hookline_direction`, `text_height`, `text_width`, `normal`, `horizontal_direction`, `block_offset`, `annotation_offset`. Read-only: `origin`, `dimension_gap`, `arrowhead_type`, `arrow_size`, `byblock_color`. `override_color` is writable through the new generator `Color` form (`{'kind': 'Rgb', 'value': {'r','g','b'}}`, `Index`, `ByLayer`, `ByBlock`, `None`). Unmapped but preserved on edit: the `dwg_unknown_*` fields. Host reference binding validates that `annotation_handle` exists, is not the leader itself, matches `creation_type` (Text/MText, Tolerance, Insert; `NoAnnotation` forbids a handle) and that `dimension_style` exists; nothing is silently defaulted. `staged_python_leader_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/P against acadrust `7ea4247`: linked Text annotation, path/height/arrow/offset transaction edit with untouched-field preservation, GUI pick grouping (scripted selection stays exact), seven single-edit rejections plus a cross-entity atomic rollback, four-step undo/redo, real render linework, DWG and DXF reopen and re-edit. Erasing a leader also erases its annotation (scene leader grouping); undo restores both. **W blockers (engine, recorded as canary assertions in the test):** (1) the DXF writer never emits group 340, so a DXF save unlinks the annotation; (2) DXF reads drop `annotation_offset` (group 213 is written but not reassembled); (3) DWG R2010+ does not store `text_height`, `text_width` or `hookline_enabled` (acadrust writes them only for R13–R2007 / R13–R14); (4) neither writer stores `override_color`, so it reopens as `ByLayer` in DWG and DXF. Not `Complete` until the engine round-trips (1) and (2); (3) needs a decision to document or make read-only. | diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index a23cd5b43..6ba318e8f 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3201,7 +3201,7 @@ mod tests { // Insert is update-only: the host makes a block and one insert, and the // "create" script only has to exist. let update_only = create_script.contains("MAKEINSERT"); - if update_only { + if update_only || create_script.contains("MAKEBLOCK") { let next = host.document().next_handle(); let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2)); let mut record = acadrust::tables::BlockRecord::new("AUDITBLK"); @@ -3217,8 +3217,11 @@ mod tests { end.common.handle = end_handle; end.common.owner_handle = record_handle; host.document_mut().add_entity(EntityType::BlockEnd(end)).unwrap(); - let insert = acadrust::entities::Insert::new("AUDITBLK", acadrust::types::Vector3::new(1.0, 2.0, 0.0)); - host.add_entity(EntityType::Insert(insert)); + if update_only { + let insert = acadrust::entities::Insert::new("AUDITBLK", acadrust::types::Vector3::new(1.0, 2.0, 0.0)); + host.add_entity(EntityType::Insert(insert)); + } + create_script = create_script.replace("BLOCKRECORD", &record_handle.value().to_string()); } let paper = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle.value()); let layer0 = host.document().layers.iter().find(|l| l.name == "0").map(|l| l.handle.value()); @@ -4241,6 +4244,68 @@ mod tests { }); } + #[test] + fn audit_python_attribute_definition_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "# MAKEBLOCK\n", + "doc.create_entity('AttributeDefinition', owner_handle=BLOCKRECORD, tag='PART_NO', prompt='Part number',\n", + " default_value='PN-001', insertion_point=P(1.0, 2.0, 0.0), height=2.5, width_factor=1.25, oblique_angle=0.1,\n", + " rotation=0.25, horizontal_alignment='Center', vertical_alignment='Top', alignment_point=P(3.0, 4.0, 0.0),\n", + " flags={'invisible': True, 'constant': False, 'verify': True, 'preset': False, 'locked_position': False, 'annotative': False},\n", + " field_length=12, text_generation_flags=2, lock_position=True)\n"), + edit: concat!( + "e = doc.entities[HANDLE]\n", + "with doc.transaction('Edit'):\n", + " e.insertion_point = (4.0, 5.0, 0.0)\n", + " e.default_value = 'PN-002'\n", + " e.prompt = 'Serial'\n", + " e.height = 3.0\n", + " e.width_factor = 0.8\n", + " e.rotation = 0.5\n", + " e.oblique_angle = 0.2\n", + " e.horizontal_alignment = 'Right'\n", + " e.vertical_alignment = 'Middle'\n", + " e.flags = {'invisible': False, 'constant': True, 'verify': False, 'preset': True, 'locked_position': False, 'annotative': False}\n", + " e.field_length = 20\n", + "doc.selection = [e]\n"), + rejects: &[ + ("'tag':'BAD TAG'", "whitespace"), + ("'height':0.0", "greater than zero"), + ("'width_factor':0.0", "width_factor"), + ("'text_style':'Missing'", "does not exist"), + ("'owner_handle':999999", "read-only"), + ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), + ("'rotation':float('nan')", "finite"), + ("'horizontal_alignment':'Nowhere'", "unsupported"), + ], + is_kind: |entity| matches!(entity, EntityType::AttributeDefinition(_)), + digest: |entity| match entity { + EntityType::AttributeDefinition(v) => format!( + "{}|{}|{} ins{:.1},{:.1} al{:.1},{:.1} h{:.1} r{:.2} wf{:.2} ob{:.2} {:?}/{:?} f{}{}{}{} fl{} tg{} lock{}", + v.tag, v.prompt, v.default_value, v.insertion_point.x, v.insertion_point.y, + v.alignment_point.x, v.alignment_point.y, v.height, v.rotation, v.width_factor, v.oblique_angle, + v.horizontal_alignment, v.vertical_alignment, + u8::from(v.flags.invisible), u8::from(v.flags.constant), u8::from(v.flags.verify), u8::from(v.flags.preset), + v.field_length, v.text_generation_flags, u8::from(v.lock_position)), + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::AttributeDefinition(v) = entity { v.default_value = "PN-003".into(); }, + expect_created: "PART_NO|Part number|PN-001 ins1.0,2.0 al3.0,4.0 h2.5 r0.25 wf1.25 ob0.10 Center/Top f1010 fl12 tg2 lock1", + expect_edited: "PART_NO|Serial|PN-002 ins4.0,5.0 al3.0,4.0 h3.0 r0.50 wf0.80 ob0.20 Right/Middle f0101 fl20 tg2 lock1", + expect_reedited: "PART_NO|Serial|PN-003 ins4.0,5.0 al3.0,4.0 h3.0 r0.50 wf0.80 ob0.20 Right/Middle f0101 fl20 tg2 lock1", + // BLOCKER: acadrust's DXF ATTDEF reader handles only groups 1, 2, 3, + // 10, 40, 50, 280 and 101, so the alignment point and alignments, + // width factor, oblique angle, attribute flags, field length, + // generation flags, lock and text style (groups 7, 11, 41, 51, 70-74, + // 210, 280 lock) reopen as defaults. The writer emits all of them. + expect_edited_dxf: "PART_NO|Serial|PN-002 ins4.0,5.0 al0.0,0.0 h3.0 r0.50 wf1.00 ob0.00 Left/Baseline f0000 fl0 tg0 lock0", + expect_reedited_dxf: "PART_NO|Serial|PN-003 ins4.0,5.0 al0.0,0.0 h3.0 r0.50 wf1.00 ob0.00 Left/Baseline f0000 fl0 tg0 lock0", + expect_edited_dwg: "", + expect_reedited_dwg: "", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From eb22de636b1346362ac32bf3ccef9730128f96f3 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 16:14:02 +0200 Subject: [PATCH 35/76] Decide Insert and Polyline: Insert creatable by block name, Polyline update-only Insert can be created by naming an existing ordinary block; the host refuses unknown blocks, model/paper space and containment cycles, and keeps legacy inserts of missing blocks editable. The legacy Polyline is update-only with a hint toward Polyline2D and Polyline3D because both writers convert it on save. The generator gains update_only_message. Co-Authored-By: Claude Sonnet 5 --- .../entity_coverage_policy.json | 4 +- crates/ocs_plugin_api/src/entity_coverage.rs | 70 +- docs/plugin-host-model-completion-plan.md | 5 +- docs/plugin-host-model-coverage-ledger.md | 6 +- plugins/opencad-python/build/generate.rs | 11 +- plugins/opencad-python/entity_manifest.json | 715 +++++++++++++----- plugins/opencad-python/src/ocs_module.rs | 40 +- src/app/plugin_host.rs | 84 +- 8 files changed, 734 insertions(+), 201 deletions(-) diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 3169a9874..549f1d767 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -175,6 +175,7 @@ ], "Insert": [ "layer", + "block_name", "insert_point", "x_scale", "y_scale", @@ -488,9 +489,6 @@ ] }, "readable": { - "Insert": [ - "block_name" - ], "Tolerance": [ "dimension_style_handle" ], diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index 54f9d1430..fe30e534a 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1330,6 +1330,24 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() EntityType::Viewport(value) => validate_viewport(None, value)?, EntityType::ViewBorder(value) => validate_view_border(None, value)?, EntityType::Light(value) => validate_light(None, value)?, + EntityType::Insert(value) => { + if value.block_name.trim().is_empty() { + return Err("Insert.block_name is empty".into()); + } + finite_vector("Insert.insert_point", &value.insert_point)?; + solid_normal("Insert.normal", &value.normal)?; + for (name, scale) in [("x_scale", value.x_scale()), ("y_scale", value.y_scale()), ("z_scale", value.z_scale())] { + insert_scale(&format!("Insert.{name}"), scale)?; + } + for (name, number) in [("rotation", value.rotation), ("column_spacing", value.column_spacing), ("row_spacing", value.row_spacing)] { + if !number.is_finite() { + return Err(format!("Insert.{name} must be finite")); + } + } + if value.column_count == 0 || value.row_count == 0 { + return Err("Insert array counts must be greater than zero".into()); + } + } EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1961,6 +1979,53 @@ pub fn validate_canvas_entity_references( return Err(format!("Shape text style {:?} has no SHX file", style.name)); } } + // An edit that keeps the block name is left alone so a legacy insert of a + // missing block stays editable; creation and renames must name a real, + // ordinary block that does not create a cycle. + let insert_is_unchanged_edit = |value: &crate::host::acadrust::entities::Insert| { + matches!(document.get_entity(value.common.handle), + Some(EntityType::Insert(existing)) if existing.block_name == value.block_name) + }; + if let Some(value) = match entity { + EntityType::Insert(value) if !insert_is_unchanged_edit(value) => Some(value), + _ => None, + } { + let name = value.block_name.trim(); + let record = document.block_records.get(name).ok_or_else(|| { + format!("Insert block {:?} does not exist", value.block_name) + })?; + if record.is_model_space() || record.is_paper_space() { + return Err("Insert cannot reference a model-space or paper-space block".into()); + } + // A block cannot (transitively) contain an insert of itself. + let owner_name = document + .block_records + .iter() + .find(|candidate| candidate.handle == value.common.owner_handle) + .map(|candidate| candidate.name.clone()); + if let Some(owner_name) = owner_name { + let mut pending = vec![record.name.clone()]; + let mut seen = std::collections::HashSet::new(); + while let Some(current) = pending.pop() { + if current.eq_ignore_ascii_case(&owner_name) { + return Err(format!( + "inserting block {:?} into {owner_name:?} would make the block contain itself", + value.block_name + )); + } + if !seen.insert(current.to_ascii_lowercase()) { + continue; + } + if let Some(block) = document.block_records.get(¤t) { + for handle in &block.entity_handles { + if let Some(EntityType::Insert(nested)) = document.get_entity(*handle) { + pending.push(nested.block_name.clone()); + } + } + } + } + } + } if let EntityType::ViewBorder(value) = entity { if !matches!(document.get_entity(value.active_viewport), Some(EntityType::Viewport(_))) { return Err(format!("ViewBorder viewport {:?} does not exist", value.active_viewport)); @@ -2984,8 +3049,9 @@ mod tests { .iter() .find(|property| property.name == "block_name") .unwrap(); - assert_eq!(block_name.model_access, ModelAccess::ReadOnly); - assert_eq!(block_name.validation, "none"); + // Named at creation; a geometry transaction can never change it (the + // mutation check rejects a different block). + assert_eq!(block_name.model_access, ModelAccess::ReadWrite); assert!( insert .properties diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 9362f43e5..fc74152dd 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -8,8 +8,8 @@ As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is v7. The Python feature has explicit writes for **37 of 43** canvas kinds: 36 -are creatable and `Insert` remains update-only. This is **not** 37 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; the audited Polyline, Polyline2D, Polyline3D, Spline and Insert pass every gate that their format or update-only status allows. +are creatable (the legacy `Polyline` is update-only) and `Insert` remains update-only. This is **not** 37 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D, Polyline3D and Spline pass every gate that their format allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current @@ -173,3 +173,4 @@ exact queue above and update statuses with evidence after each increment. - Recorded `Region`, `Body`, `Solid3D`, `Surface` (queue items 17-20) and `Ole2Frame` (item 21) as **blocked**. The ACIS kinds need a host-side kernel-backed create/transform API that regenerates the payload, wires and silhouettes; the OLE frame needs a valid embedded-storage fixture. No opaque payload is rewritten. With this the ordered queue is complete: 12 kinds are fully complete, Leader, Helix, Underlay, ViewBorder and Light are mapped but blocked at DWG/DXF persistence by acadrust gaps, RasterImage and Viewport lack an oracle or linkage, and six kinds are blocked. Next work is the engine fixes behind the canary assertions, the mapped-kind audit of the 17 pre-existing kinds, and merging `origin/main`. - Audited the 17 pre-existing mapped kinds through the shared real-runner driver (`audit_python_*`). The audit found and fixed genuine validation gaps: Ellipse had none, Text and MText accepted a zero height, Arc accepted non-finite angles, Point and Line accepted infinite thickness, and most basic kinds had no creation-time validation. A shared `validate_basic_entity` now covers Point, Line, Circle, Arc, Ellipse, Text, MText, LwPolyline, Polyline, Polyline2D, Polyline3D and Spline, refusing only problems an edit introduces so already-invalid legacy values stay editable. Twelve of the 17 kinds now pass every gate (Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid, Face3D). Four expose persistence findings pinned by canaries (Polyline reopens as Polyline3D; Polyline2D reopens as LwPolyline in DXF; Polyline3D loses elevation in DWG; Spline loses weights in DXF), and Insert is update-only with all applicable gates passing. The driver gained per-format DWG expectations and a host-made block fixture for Insert. - Audited `AttributeDefinition` against acadrust `5b682ed` with a full-field comparison through both formats. Everything passes in DWG. DXF keeps only tag, prompt, value, insertion point, height and rotation because the reader handles eight group codes; alignment, width factor, oblique angle, attribute flags, field length, generation flags, lock and text style reopen as defaults. The kind stays integration-tested and the gap is pinned by canary expectations; the fix belongs in cadcodec's `read_attdef`. The shared driver's block fixture now also serves `MAKEBLOCK` scripts. +- Decided the two open audit questions. **Insert is creatable** by naming an existing block (existence, ordinary-block and no-cycle checks; attribute records stay with the AttributeEntity flow), which completes the kind. **Legacy Polyline is update-only** with a hint pointing scripts at Polyline2D and Polyline3D, because both formats convert it on save. The generator gained an `update_only_message` for such kinds, integer coordinates are accepted in Insert scales, and the driver gained `MAKEPOLYLINE` and `MAKEBLOCK` fixtures. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index be6a96235..931abf8b9 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only `Insert`), **24 kinds are complete**, and 6 remain layer-only (one of them, SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only legacy `Polyline`), **25 kinds are complete**, and 6 remain layer-only (one of them, SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -11,7 +11,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Circle | **Complete** | Exercised: `radius`, `center`. Rejects a non-positive or non-finite radius, non-finite center and thickness, and a zero normal. `audit_python_circle_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Arc | **Complete** | Exercised: `end_angle`, `radius`. Rejects a non-positive radius, non-finite angles (previously accepted), center and thickness, and a zero normal. `audit_python_arc_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Ellipse | **Complete** | Exercised: `minor_axis_ratio`, `end_parameter`. The audit found Ellipse had no validation at all; it now requires a nonzero major axis, a ratio within (0, 1], finite parameters and a nonzero normal. `audit_python_ellipse_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | -| Polyline | **Integration-tested / kind changes on save** | Exercised: `vertices`, `closed`. The legacy `Polyline` (3-D vertices) passes create, edit, reject, undo and canvas gates, and its vertices and closed state survive, but both writers save it as a 3-D polyline, so it reopens from DWG and DXF as `Polyline3D`. Pinned by per-format canary expectations. Decide whether to keep `Polyline` scriptable or steer scripts to `Polyline3D`. `audit_python_polyline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Polyline | **Update-only (decision)** | Decision (2026-09-20): the legacy `Polyline` (3-D vertices) is not creatable from Python, because both file formats save it as `Polyline3D` and it would silently change kind on the first save. Creating one now fails with a message that names `Polyline2D` and `Polyline3D`. Existing `Polyline` entities remain editable: `audit_python_polyline_lifecycle_over_real_ipc` (host-made fixture) passes E/D/U/I/V with vertex and closed edits, canvas wires, rejection of short and non-finite vertex lists, an atomic rollback and three-step undo/redo, and pins the reopen behavior (both DWG and DXF return a `Polyline3D` with the vertices and closure intact). Scripts should create `Polyline2D` or `Polyline3D`. | | Polyline2D | **Integration-tested / kind changes on DXF save** | Exercised: `vertices`, `closed`, `thickness`. Passes every gate in DWG. DXF reopens a 2-D polyline as an `LwPolyline` (vertices, closure and width survive, but the kind changes and `thickness` is not part of that record's digest here); pinned by a canary expectation. `audit_python_polyline2d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Polyline3D | **Integration-tested / blocked at W by the DWG format (elevation)** | Exercised: `vertices`, `elevation`. Passes every gate in DXF; DWG does not store a 3-D polyline's `elevation`, so it reopens as 0 (pinned by a canary). Vertices persist. `audit_python_polyline3d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | LwPolyline | **Complete** | Exercised: `vertices` (with a bulge), `is_closed`, `constant_width`. Rejects fewer than 2 vertices, non-finite locations, bulges and widths, a negative constant width and a zero normal. `audit_python_lwpolyline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | @@ -22,7 +22,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | XLine | **Complete** | Exercised: `base_point`, `direction`. Same as Ray. `audit_python_xline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Solid | **Complete** | Exercised: `fourth_corner`, `thickness`. Finite corners, thickness and normal validation already existed; the audit added the full gate. `audit_python_solid_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Face3D | **Complete** | Exercised: `third_corner`, `invisible_edges`. Finite corners and known edge bits validation already existed; the audit added the full gate. `audit_python_face3d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | -| Insert | **Update-only (all gates pass)** | Exercised: `insert_point`, `x_scale`, `y_scale`, `rotation`, `column_count`, `column_spacing`. `audit_python_insert_lifecycle_over_real_ipc` passes E/D/U/I/W/V/P with a host-made block and insert: a six-property transaction edit, canvas wires, rejection of a zero scale, non-finite rotation and point, a zero array count, a zero normal and a `block_name` change (read-only), an atomic rollback, DWG and DXF reopen with re-edit, and undo/redo. Not `Complete` because Python cannot create an Insert (creation and block-reference semantics are an open decision: permanent update-only, or a create path that names an existing block). | +| Insert | **Complete** | Decision (2026-09-20): Insert is creatable. A script names an existing block with `block_name` and a placement; the host does not generate attribute records, because attributes stay with the `AttributeEntity` flow that already creates them from the block's ATTDEFs. Writable: `block_name` (fixed after creation; the mutation check refuses a different block), `insert_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `column_count`, `row_count`, `column_spacing`, `row_spacing`. Creation and rename rules: the block must exist, must not be model or paper space, and must not make a block contain itself directly or through nested inserts; scales must be finite and nonzero, array counts positive, and the name non-empty. An edit that keeps the block name is exempt from the block checks so a legacy insert of a missing block stays editable. `audit_python_insert_lifecycle_over_real_ipc` (shared driver, host-made block) passes C/R/E/D/U/I/W/V/P: creation by block name, a six-property transaction edit, canvas wires, rejection of zero scale, non-finite rotation and point, zero array count, zero normal and an unknown block, an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. `audit_python_insert_creation_rules_over_real_ipc` covers unknown block, model-space block, bad scale, zero count, empty name, direct self-insertion and indirect nesting cycles. Not covered: MINSERT (`dwg_minsert`) and attached-attribute creation, which stay excluded and preserved. | | Tolerance | **Complete** | `staged_python_tolerance_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with the staged API v7 plugin: high-level create/read/transaction edit/delete; insertion, direction, and text edits; live canvas selection; grouped undo/redo; invalid direction and missing style atomic rollback; exact untouched common/style/private-field preservation; and edited DWG plus DXF reopen. Host-owned coverage exposes placement, direction, normal, text, style name, height and gap; style handle is read-only and the undocumented DWG short remains unmapped. `tolerance_geometry_and_style_references_are_validated` and the existing Tolerance tessellation tests cover reference and rendered-frame behavior. | | Shape | **Complete** | `staged_python_shape_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P with a generated parser-valid SHX file and a real shape-file `TextStyle`: high-level create/read/transaction edit/delete; insertion, size, rotation and width-factor edits; live canvas selection; grouped undo/redo; invalid size and missing style rollback; exact identity/style/common-field preservation; and DWG plus DXF reopen. Each live and reopened document renders the three-point SHX glyph rather than the placeholder. Host-owned reference binding writes the stable style handle needed by DWG. The DXF fallback now searches all `is_shape_file` styles, preserving named-style glyph resolution after reopen. | | AttributeDefinition | **Integration-tested / blocked at W by acadrust (DXF ATTDEF reader)** | Host commit `5b854bd1` maps block ownership plus every stable ATTDEF field except the nested `embedded_mtext` payload, which is snapshot-readable and preserved untouched. Two staged tests cover it. `staged_python_attribute_definition_lifecycle_over_real_ipc` passes with a real block record, Block/BlockEnd markers and a linked Insert. The 2026-09-20 audit `audit_python_attribute_definition_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, host-made block) compares **every writable field** through both formats: creation with non-default tag, prompt, value, placement, alignment point, height, rotation, width factor, oblique angle, alignments, all attribute flags, field length, generation flags and lock; a nine-property transaction edit; canvas wires; eight rejection cases (whitespace tag, non-positive height and width factor, missing style, owner change, zero normal, non-finite rotation, unknown alignment) plus an atomic rollback; DWG reopen with the **complete** edited state and re-edit; deletion; and three-step undo/redo. **W blocker, revalidated on acadrust `5b682ed`:** the DXF writer emits every ATTDEF group, but the reader handles only groups 1, 2, 3, 10, 40, 50, 280 and 101, so a DXF reopen keeps tag, prompt, value, insertion point, height and rotation and resets the alignment point, both alignments, width factor, oblique angle, all attribute flags (invisible, constant, verify, preset), field length, generation flags, lock and text style to defaults. This is now pinned by canary expectations for both the edit and the re-edit. Not `Complete` until the reader is extended; the fix belongs in cadcodec's `read_attdef`. `embedded_mtext` mutation remains unsupported pending linked-layout validation. | diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index 219747fee..c07b9c39d 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -55,6 +55,9 @@ pub struct EntityOverride { /// create one without host-side reference setup. #[serde(default)] pub update_only: bool, + /// Message returned when a script tries to create an update-only kind. + #[serde(default)] + pub update_only_message: Option, #[serde(default)] pub fields: std::collections::BTreeMap, } @@ -874,7 +877,13 @@ pub(crate) fn entity_to_dict(vm: &VirtualMachine, entity: &acadrust::EntityType) fn gen_dict_to_entity(manifest: &Manifest, registry: &TypeRegistry) -> String { let mut arms = String::new(); for kind in &manifest.type_filter { - if manifest.overrides.get(kind).is_some_and(|override_def| override_def.update_only) { + if let Some(override_def) = manifest.overrides.get(kind).filter(|o| o.update_only) { + let message = override_def.update_only_message.clone().unwrap_or_else(|| { + format!("{kind} cannot be created through the generic API; only existing {kind} entities can be edited") + }); + arms.push_str(&format!( + " \"{kind}\" => {{ return Err(vm.new_value_error({message:?}.to_owned())); }}\n" + )); continue; } if manifest.manual_kinds.contains(kind) { diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 6f0e0f87a..5126e4464 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -38,54 +38,90 @@ "ViewBorder", "Light" ], - "manual_kinds": ["Dimension"], - "base_fields": ["handle", "layer", "owner_handle"], + "manual_kinds": [ + "Dimension" + ], + "base_fields": [ + "handle", + "layer", + "owner_handle" + ], "overrides": { "Point": { "fields": { - "location": { "required": true } + "location": { + "required": true + } } }, "Line": { "fields": { - "start": { "required": true }, - "end": { "required": true } + "start": { + "required": true + }, + "end": { + "required": true + } } }, "Circle": { "fields": { - "center": { "required": true }, - "radius": { "required": true } + "center": { + "required": true + }, + "radius": { + "required": true + } } }, "Arc": { "fields": { - "center": { "required": true }, - "radius": { "required": true }, - "start_angle": { "required": true }, - "end_angle": { "required": true } + "center": { + "required": true + }, + "radius": { + "required": true + }, + "start_angle": { + "required": true + }, + "end_angle": { + "required": true + } } }, "Ellipse": { "fields": { - "center": { "required": true }, - "major_axis": { "required": true }, - "minor_axis_ratio": { "required": true } + "center": { + "required": true + }, + "major_axis": { + "required": true + }, + "minor_axis_ratio": { + "required": true + } } }, "Polyline": { "fields": { - "vertices": { "required": true }, + "vertices": { + "required": true + }, "flags": { "python_name": "closed", "rust_getter": "VAR.flags.is_closed()", "rust_setter": "VAR.flags.set_closed(get_opt_bool(dict, \"closed\", vm)?);" } - } + }, + "update_only": true, + "update_only_message": "Polyline is the legacy 3-D-vertex polyline and both file formats save it as Polyline3D; create a Polyline2D or Polyline3D instead (existing Polyline entities can still be edited)" }, "Polyline2D": { "fields": { - "vertices": { "required": true }, + "vertices": { + "required": true + }, "flags": { "python_name": "closed", "rust_getter": "VAR.flags.is_closed()", @@ -95,121 +131,200 @@ }, "Polyline3D": { "fields": { - "vertices": { "required": true }, + "vertices": { + "required": true + }, "smooth_type": { "exclude": true, - "_comment": "acadrust defines two distinct SmoothSurfaceType enums with the same name (polyline.rs, used by Polyline2D's smooth_surface — and a separate one private to polyline3d.rs, used here); the type registry's tracer keys purely by name and collapses them, so the generically generated conversion resolves to the wrong one. Needs a hand-written override with the fully qualified acadrust::entities::polyline3d::SmoothSurfaceType path to support, not just a rename." + "_comment": "acadrust defines two distinct SmoothSurfaceType enums with the same name (polyline.rs, used by Polyline2D's smooth_surface \u2014 and a separate one private to polyline3d.rs, used here); the type registry's tracer keys purely by name and collapses them, so the generically generated conversion resolves to the wrong one. Needs a hand-written override with the fully qualified acadrust::entities::polyline3d::SmoothSurfaceType path to support, not just a rename." } } }, "LwPolyline": { "fields": { - "vertices": { "required": true } + "vertices": { + "required": true + } } }, "Spline": { "fields": { - "control_points": { "required": true } + "control_points": { + "required": true + } } }, "Text": { "fields": { - "value": { "python_name": "text", "required": true }, - "insertion_point": { "python_name": "insertion", "required": true } + "value": { + "python_name": "text", + "required": true + }, + "insertion_point": { + "python_name": "insertion", + "required": true + } } }, "MText": { "fields": { - "value": { "python_name": "text", "required": true }, - "insertion_point": { "python_name": "insertion", "required": true }, + "value": { + "python_name": "text", + "required": true + }, + "insertion_point": { + "python_name": "insertion", + "required": true + }, "background_color": { "exclude": true, - "_comment": "Color is a tagged enum (Index(u8)/Rgb{r,g,b} carry data, not just unit variants) — this generator only handles C-like unit enums generically; EntityCommon's own color/line_weight/transparency fields are out of scope for the same reason (see base_fields)." + "_comment": "Color is a tagged enum (Index(u8)/Rgb{r,g,b} carry data, not just unit variants) \u2014 this generator only handles C-like unit enums generically; EntityCommon's own color/line_weight/transparency fields are out of scope for the same reason (see base_fields)." } } }, "Ray": { "fields": { - "base_point": { "required": true }, - "direction": { "required": true } + "base_point": { + "required": true + }, + "direction": { + "required": true + } } }, "XLine": { "fields": { - "base_point": { "required": true }, - "direction": { "required": true } + "base_point": { + "required": true + }, + "direction": { + "required": true + } } }, "Solid": { "rust_constructor": "acadrust::entities::Solid::new(acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO)", "fields": { - "first_corner": { "required": true }, - "second_corner": { "required": true }, - "third_corner": { "required": true }, - "fourth_corner": { "required": true } + "first_corner": { + "required": true + }, + "second_corner": { + "required": true + }, + "third_corner": { + "required": true + }, + "fourth_corner": { + "required": true + } } }, "Face3D": { "rust_constructor": "acadrust::entities::Face3D::new(acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO, acadrust::types::Vector3::ZERO)", "fields": { - "first_corner": { "required": true }, - "second_corner": { "required": true }, - "third_corner": { "required": true }, - "fourth_corner": { "required": true } + "first_corner": { + "required": true + }, + "second_corner": { + "required": true + }, + "third_corner": { + "required": true + }, + "fourth_corner": { + "required": true + } } }, "Insert": { - "update_only": true, "fields": { - "block_name": { "read_only": true }, + "block_name": { + "required": true + }, "x_scale": { "rust_getter": "VAR.x_scale()", - "rust_setter": "if let Some(v) = dict.get_item_opt(\"x_scale\", vm)? { let scale = v.try_into_value::(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.x_scale must be finite and nonzero\".to_owned())); } VAR.set_x_scale(scale); }" + "rust_setter": "if let Some(v) = dict.get_item_opt(\"x_scale\", vm)? { let scale = py_number_to_f64(v, vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.x_scale must be finite and nonzero\".to_owned())); } VAR.set_x_scale(scale); }" }, "y_scale": { "rust_getter": "VAR.y_scale()", - "rust_setter": "if let Some(v) = dict.get_item_opt(\"y_scale\", vm)? { let scale = v.try_into_value::(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.y_scale must be finite and nonzero\".to_owned())); } VAR.set_y_scale(scale); }" + "rust_setter": "if let Some(v) = dict.get_item_opt(\"y_scale\", vm)? { let scale = py_number_to_f64(v, vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.y_scale must be finite and nonzero\".to_owned())); } VAR.set_y_scale(scale); }" }, "z_scale": { "rust_getter": "VAR.z_scale()", - "rust_setter": "if let Some(v) = dict.get_item_opt(\"z_scale\", vm)? { let scale = v.try_into_value::(vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.z_scale must be finite and nonzero\".to_owned())); } VAR.set_z_scale(scale); }" + "rust_setter": "if let Some(v) = dict.get_item_opt(\"z_scale\", vm)? { let scale = py_number_to_f64(v, vm)?; if !scale.is_finite() || scale.abs() < 1e-12 { return Err(vm.new_value_error(\"Insert.z_scale must be finite and nonzero\".to_owned())); } VAR.set_z_scale(scale); }" }, - "dwg_minsert": { "exclude": true }, - "attributes": { "exclude": true }, - "view_rep_handle": { "exclude": true }, - "seqend_handle": { "exclude": true } - } + "dwg_minsert": { + "exclude": true + }, + "attributes": { + "exclude": true + }, + "view_rep_handle": { + "exclude": true + }, + "seqend_handle": { + "exclude": true + }, + "insert_point": { + "required": true + } + }, + "rust_constructor": "acadrust::entities::Insert::new(String::new(), acadrust::types::Vector3::ZERO)" }, "Tolerance": { "fields": { - "insertion_point": { "required": true }, - "text": { "required": true }, + "insertion_point": { + "required": true + }, + "text": { + "required": true + }, "dimension_style_name": { "rust_getter": "VAR.dimension_style_name.clone()", "rust_setter": "if let Some(v) = dict.get_item_opt(\"dimension_style_name\", vm)? { VAR.dimension_style_name = v.try_into_value::(vm)?; VAR.dimension_style_handle = None; }" }, - "dimension_style_handle": { "read_only": true }, - "dwg_unknown_short": { "exclude": true } + "dimension_style_handle": { + "read_only": true + }, + "dwg_unknown_short": { + "exclude": true + } } }, "Shape": { "fields": { - "insertion_point": { "required": true }, - "size": { "required": true }, - "shape_name": { "required": true }, - "shape_number": { "required": true }, + "insertion_point": { + "required": true + }, + "size": { + "required": true + }, + "shape_name": { + "required": true + }, + "shape_number": { + "required": true + }, "style_name": { "rust_getter": "VAR.style_name.clone()", "rust_setter": "if let Some(v) = dict.get_item_opt(\"style_name\", vm)? { VAR.style_name = v.try_into_value::(vm)?; VAR.style_handle = None; }" }, - "style_handle": { "read_only": true } + "style_handle": { + "read_only": true + } } }, "AttributeDefinition": { "fields": { - "tag": { "required": true }, - "insertion_point": { "required": true }, - "height": { "required": true }, + "tag": { + "required": true + }, + "insertion_point": { + "required": true + }, + "height": { + "required": true + }, "embedded_mtext": { "exclude": true, "_comment": "Embedded R2018+ MTEXT carries linked layout state. It remains snapshot-readable but is preserved untouched by Python edits until linked-payload mutation has dedicated validation." @@ -218,11 +333,21 @@ }, "AttributeEntity": { "fields": { - "tag": { "required": true }, - "value": { "required": true }, - "insertion_point": { "required": true }, - "height": { "required": true }, - "attdef_handle": { "read_only": true }, + "tag": { + "required": true + }, + "value": { + "required": true + }, + "insertion_point": { + "required": true + }, + "height": { + "required": true + }, + "attdef_handle": { + "read_only": true + }, "embedded_mtext": { "exclude": true, "_comment": "The embedded R2018+ MTEXT payload remains snapshot-readable and is preserved by partial edits." @@ -231,7 +356,9 @@ }, "Hatch": { "fields": { - "paths": { "required": true }, + "paths": { + "required": true + }, "is_mpolygon": { "exclude": true, "_comment": "MPOLYGON is a distinct class-backed record and is preserved without exposing Hatch creation semantics." @@ -240,21 +367,35 @@ "exclude": true, "_comment": "Gradient payload mutation requires a separate validated model; partial Hatch edits preserve it." }, - "mpolygon_hatch_color": { "exclude": true }, - "mpolygon_x_direction": { "exclude": true }, - "mpolygon_boundary_handle_count": { "exclude": true } + "mpolygon_hatch_color": { + "exclude": true + }, + "mpolygon_x_direction": { + "exclude": true + }, + "mpolygon_boundary_handle_count": { + "exclude": true + } } }, "MLine": { "fields": { - "vertices": { "required": true }, + "vertices": { + "required": true + }, "flags": { "rust_getter": "(VAR.flags.bits() as i64)", "rust_setter": "if let Some(v) = dict.get_item_opt(\"flags\", vm)? { let bits = v.try_into_value::(vm)?; VAR.flags = i16::try_from(bits).ok().and_then(acadrust::entities::MLineFlags::from_bits).ok_or_else(|| vm.new_value_error(\"MLine.flags has unknown bits\".to_owned()))?; }" }, - "start_point": { "read_only": true }, - "style_handle": { "read_only": true }, - "style_element_count": { "read_only": true }, + "start_point": { + "read_only": true + }, + "style_handle": { + "read_only": true + }, + "style_element_count": { + "read_only": true + }, "style_name": { "rust_getter": "VAR.style_name.clone()", "rust_setter": "if let Some(v) = dict.get_item_opt(\"style_name\", vm)? { VAR.style_name = v.try_into_value::(vm)?; VAR.style_handle = None; }" @@ -263,28 +404,67 @@ }, "Leader": { "fields": { - "vertices": { "required": true }, - "origin": { "read_only": true }, - "dimension_gap": { "read_only": true }, - "arrowhead_type": { "read_only": true }, - "arrow_size": { "read_only": true }, - "byblock_color": { "read_only": true }, - "dwg_unknown_bit1": { "exclude": true }, - "dwg_unknown_bit2": { "exclude": true }, - "dwg_unknown_bit3": { "exclude": true }, - "dwg_unknown_bit4": { "exclude": true }, - "dwg_unknown_bit5": { "exclude": true }, - "dwg_unknown_short1": { "exclude": true } + "vertices": { + "required": true + }, + "origin": { + "read_only": true + }, + "dimension_gap": { + "read_only": true + }, + "arrowhead_type": { + "read_only": true + }, + "arrow_size": { + "read_only": true + }, + "byblock_color": { + "read_only": true + }, + "dwg_unknown_bit1": { + "exclude": true + }, + "dwg_unknown_bit2": { + "exclude": true + }, + "dwg_unknown_bit3": { + "exclude": true + }, + "dwg_unknown_bit4": { + "exclude": true + }, + "dwg_unknown_bit5": { + "exclude": true + }, + "dwg_unknown_short1": { + "exclude": true + } } }, "MultiLeader": { "fields": { - "arrowhead_overrides": { "exclude": true, "_comment": "Per-arrow override records carry linked block handles; preserved untouched until dedicated validation exists." }, - "block_attributes": { "exclude": true, "_comment": "Block attribute records carry attribute-definition handles; preserved untouched." }, - "dwg_version": { "read_only": true }, - "text_height": { "read_only": true, "_comment": "Neither writer stores the entity-level text height; scripts set the persisted context.text_height." }, - "property_override_flags": { "read_only": true }, - "text_align_in_ipe": { "read_only": true }, + "arrowhead_overrides": { + "exclude": true, + "_comment": "Per-arrow override records carry linked block handles; preserved untouched until dedicated validation exists." + }, + "block_attributes": { + "exclude": true, + "_comment": "Block attribute records carry attribute-definition handles; preserved untouched." + }, + "dwg_version": { + "read_only": true + }, + "text_height": { + "read_only": true, + "_comment": "Neither writer stores the entity-level text height; scripts set the persisted context.text_height." + }, + "property_override_flags": { + "read_only": true + }, + "text_align_in_ipe": { + "read_only": true + }, "context": { "keep_types": true, "rust_getter": "multi_leader_annot_context_to_dict(vm, &VAR.context)?", @@ -294,132 +474,311 @@ }, "Table": { "fields": { - "insertion_point": { "required": true }, - "block_name": { "read_only": true }, - "block_record_handle": { "read_only": true }, - "data_version": { "read_only": true }, - "value_flags": { "read_only": true }, - "override_flag": { "read_only": true }, - "override_border_color": { "read_only": true }, - "override_border_line_weight": { "read_only": true }, - "override_border_visibility": { "read_only": true }, - "legacy_style_override": { "read_only": true }, - "name": { "read_only": true }, - "description": { "read_only": true }, - "field_handles": { "read_only": true }, - "base_style": { "read_only": true }, - "break_data": { "read_only": true }, - "break_ranges": { "read_only": true }, - "dwg_unknown_byte": { "exclude": true }, - "dwg_unknown_handle": { "exclude": true }, - "dwg_unknown_long1": { "exclude": true }, - "dwg_r2010_unknown_bit": { "exclude": true }, - "dwg_unknown_long2": { "exclude": true }, - "dwg_unknown_short": { "exclude": true }, - "legacy_border_colors": { "exclude": true, "_comment": "Generic LegacyBorderOverrides is not expressible by the generator; legacy R13-R2000 border overrides are preserved untouched." }, - "legacy_border_line_weights": { "exclude": true }, - "legacy_border_visibility": { "exclude": true } + "insertion_point": { + "required": true + }, + "block_name": { + "read_only": true + }, + "block_record_handle": { + "read_only": true + }, + "data_version": { + "read_only": true + }, + "value_flags": { + "read_only": true + }, + "override_flag": { + "read_only": true + }, + "override_border_color": { + "read_only": true + }, + "override_border_line_weight": { + "read_only": true + }, + "override_border_visibility": { + "read_only": true + }, + "legacy_style_override": { + "read_only": true + }, + "name": { + "read_only": true + }, + "description": { + "read_only": true + }, + "field_handles": { + "read_only": true + }, + "base_style": { + "read_only": true + }, + "break_data": { + "read_only": true + }, + "break_ranges": { + "read_only": true + }, + "dwg_unknown_byte": { + "exclude": true + }, + "dwg_unknown_handle": { + "exclude": true + }, + "dwg_unknown_long1": { + "exclude": true + }, + "dwg_r2010_unknown_bit": { + "exclude": true + }, + "dwg_unknown_long2": { + "exclude": true + }, + "dwg_unknown_short": { + "exclude": true + }, + "legacy_border_colors": { + "exclude": true, + "_comment": "Generic LegacyBorderOverrides is not expressible by the generator; legacy R13-R2000 border overrides are preserved untouched." + }, + "legacy_border_line_weights": { + "exclude": true + }, + "legacy_border_visibility": { + "exclude": true + } } }, "PolygonMesh": { "rust_constructor": "acadrust::entities::PolygonMeshEntity::default()", - "fields": { "vertices": { "required": true } } + "fields": { + "vertices": { + "required": true + } + } }, "PolyfaceMesh": { "fields": { - "vertices": { "required": true }, - "faces": { "required": true }, - "seqend_handle": { "read_only": true } + "vertices": { + "required": true + }, + "faces": { + "required": true + }, + "seqend_handle": { + "read_only": true + } } }, "Mesh": { "fields": { - "vertices": { "required": true }, - "faces": { "required": true }, - "version": { "read_only": true } + "vertices": { + "required": true + }, + "faces": { + "required": true + }, + "version": { + "read_only": true + } } }, "Helix": { "fields": { - "start_point": { "required": true }, - "spline": { "read_only": true, "_comment": "Derived by the host from the generating parameters." }, - "major_version": { "read_only": true }, - "maintenance_version": { "read_only": true } + "start_point": { + "required": true + }, + "spline": { + "read_only": true, + "_comment": "Derived by the host from the generating parameters." + }, + "major_version": { + "read_only": true + }, + "maintenance_version": { + "read_only": true + } } }, "RasterImage": { "fields": { - "insertion_point": { "required": true }, - "file_path": { "required": true }, - "class_version": { "read_only": true }, - "size": { "read_only": true, "_comment": "Pixel size of the image; the host takes it from the file." }, - "definition_handle": { "read_only": true }, - "definition_reactor_handle": { "read_only": true } + "insertion_point": { + "required": true + }, + "file_path": { + "required": true + }, + "class_version": { + "read_only": true + }, + "size": { + "read_only": true, + "_comment": "Pixel size of the image; the host takes it from the file." + }, + "definition_handle": { + "read_only": true + }, + "definition_reactor_handle": { + "read_only": true + } } }, "Wipeout": { "fields": { - "insertion_point": { "required": true }, - "class_version": { "read_only": true }, - "definition_handle": { "read_only": true }, - "definition_reactor_handle": { "read_only": true } + "insertion_point": { + "required": true + }, + "class_version": { + "read_only": true + }, + "definition_handle": { + "read_only": true + }, + "definition_reactor_handle": { + "read_only": true + } } }, "Underlay": { "fields": { - "definition_handle": { "required": true }, - "insertion_point": { "required": true } + "definition_handle": { + "required": true + }, + "insertion_point": { + "required": true + } } }, "Viewport": { "fields": { - "id": { "read_only": true }, - "snap_base": { "read_only": true }, - "snap_spacing": { "read_only": true }, - "grid_spacing": { "read_only": true }, - "snap_angle": { "read_only": true }, - "clip_boundary_handle": { "read_only": true }, - "style_sheet": { "read_only": true }, - "ucs_at_origin": { "read_only": true }, - "ucs_per_viewport": { "read_only": true }, - "ucs_origin": { "read_only": true }, - "ucs_x_axis": { "read_only": true }, - "ucs_y_axis": { "read_only": true }, - "ucs_handle": { "read_only": true }, - "base_ucs_handle": { "read_only": true }, - "ucs_ortho_type": { "read_only": true }, - "elevation": { "read_only": true }, - "shade_plot_mode": { "read_only": true }, - "grid_flags": { "read_only": true }, - "grid_major": { "read_only": true }, - "background_handle": { "read_only": true }, - "shade_plot_handle": { "read_only": true }, - "visual_style_handle": { "read_only": true }, - "sun_handle": { "read_only": true }, - "default_lighting": { "read_only": true }, - "default_lighting_type": { "read_only": true }, - "brightness": { "read_only": true }, - "contrast": { "read_only": true }, - "ambient_color": { "read_only": true }, - "center": { "required": true }, - "width": { "required": true }, - "height": { "required": true } + "id": { + "read_only": true + }, + "snap_base": { + "read_only": true + }, + "snap_spacing": { + "read_only": true + }, + "grid_spacing": { + "read_only": true + }, + "snap_angle": { + "read_only": true + }, + "clip_boundary_handle": { + "read_only": true + }, + "style_sheet": { + "read_only": true + }, + "ucs_at_origin": { + "read_only": true + }, + "ucs_per_viewport": { + "read_only": true + }, + "ucs_origin": { + "read_only": true + }, + "ucs_x_axis": { + "read_only": true + }, + "ucs_y_axis": { + "read_only": true + }, + "ucs_handle": { + "read_only": true + }, + "base_ucs_handle": { + "read_only": true + }, + "ucs_ortho_type": { + "read_only": true + }, + "elevation": { + "read_only": true + }, + "shade_plot_mode": { + "read_only": true + }, + "grid_flags": { + "read_only": true + }, + "grid_major": { + "read_only": true + }, + "background_handle": { + "read_only": true + }, + "shade_plot_handle": { + "read_only": true + }, + "visual_style_handle": { + "read_only": true + }, + "sun_handle": { + "read_only": true + }, + "default_lighting": { + "read_only": true + }, + "default_lighting_type": { + "read_only": true + }, + "brightness": { + "read_only": true + }, + "contrast": { + "read_only": true + }, + "ambient_color": { + "read_only": true + }, + "center": { + "required": true + }, + "width": { + "required": true + }, + "height": { + "required": true + } } }, "ViewBorder": { "fields": { - "version": { "read_only": true }, - "min": { "required": true }, - "max": { "required": true }, - "active_viewport": { "required": true }, - "scale_handle": { "required": true } + "version": { + "read_only": true + }, + "min": { + "required": true + }, + "max": { + "required": true + }, + "active_viewport": { + "required": true + }, + "scale_handle": { + "required": true + } } }, "Light": { "fields": { - "class_version": { "read_only": true }, - "position": { "required": true }, - "name": { "required": true } + "class_version": { + "read_only": true + }, + "position": { + "required": true + }, + "name": { + "required": true + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index adb89de84..f6b565f56 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -3851,7 +3851,7 @@ mod ocs { #[cfg(feature = "experimental-host-model")] #[test] - fn insert_is_update_only_and_preserves_block_identity() { + fn insert_is_created_by_block_name_and_preserves_block_identity() { with_vm(|vm| { let mut insert = acadrust::entities::Insert::new("DOOR", Vector3::new(1.0, 2.0, 0.0)); @@ -3877,7 +3877,14 @@ mod ocs { .unwrap(), 2.0 ); - assert!(dict_to_entity(&rendered, vm).is_err()); + // A round-tripped dict names an existing block, so it creates one. + let recreated = dict_to_entity(&rendered, vm).unwrap(); + assert!(matches!(&recreated, EntityType::Insert(value) + if value.block_name == "DOOR" && value.x_scale() == 2.0)); + // Creation must name the block. + let unnamed = vm.ctx.new_dict(); + unnamed.set_item("kind", vm.new_pyobj("Insert"), vm).unwrap(); + assert!(dict_to_entity(&unnamed, vm).is_err()); let patch = vm.ctx.new_dict(); set_vector3(vm, &patch, "insert_point", 4.0, 5.0, 0.0); @@ -3896,7 +3903,10 @@ mod ocs { patch .set_item("block_name", vm.new_pyobj("OTHER"), vm) .unwrap(); - assert!(apply_dict_to_entity(&original, &patch, vm).is_err()); + // The adapter only converts; the host mutation check refuses a + // different block, so an edit cannot swap block identity. + let renamed = apply_dict_to_entity(&original, &patch, vm).unwrap(); + assert!(ocs_plugin_api::entity_coverage::validate_entity_mutation(&original, &renamed).is_err()); patch.del_item("block_name", vm).unwrap(); patch.set_item("z_scale", vm.new_pyobj(0.0), vm).unwrap(); assert!(apply_dict_to_entity(&original, &patch, vm).is_err()); @@ -4387,7 +4397,7 @@ mod ocs { ); let dict = vm.ctx.new_dict(); - dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap(); let err = dict_to_entity(&dict, vm).expect_err("missing vertices must error"); let message = err.args().as_slice()[0] .clone() @@ -4401,7 +4411,7 @@ mod ocs { // An explicitly empty vertex list is exactly as invalid as an // absent one — both parse to the same empty Vec. let dict = vm.ctx.new_dict(); - dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap(); dict.set_item("vertices", vm.ctx.new_list(vec![]).into(), vm) .unwrap(); assert!(dict_to_entity(&dict, vm).is_err()); @@ -4409,10 +4419,24 @@ mod ocs { } #[test] - fn polyline_flags_override_exposes_closed_as_a_plain_bool() { + fn legacy_polyline_creation_is_refused_with_a_hint() { with_vm(|vm| { let dict = vm.ctx.new_dict(); dict.set_item("kind", vm.new_pyobj("Polyline"), vm).unwrap(); + let err = dict_to_entity(&dict, vm).expect_err("legacy Polyline is update-only"); + let message = err.args().as_slice()[0] + .clone() + .try_into_value::(vm) + .unwrap(); + assert!(message.contains("Polyline2D or Polyline3D"), "{message}"); + }); + } + + #[test] + fn polyline_flags_override_exposes_closed_as_a_plain_bool() { + with_vm(|vm| { + let dict = vm.ctx.new_dict(); + dict.set_item("kind", vm.new_pyobj("Polyline2D"), vm).unwrap(); dict.set_item("closed", vm.new_pyobj(true), vm).unwrap(); let vertex = vm.ctx.new_dict(); let location = vm.ctx.new_dict(); @@ -4424,8 +4448,8 @@ mod ocs { .unwrap(); let entity = dict_to_entity(&dict, vm).expect("dict_to_entity"); - let acadrust::EntityType::Polyline(polyline) = &entity else { - panic!("expected Polyline, got {entity:?}"); + let acadrust::EntityType::Polyline2D(polyline) = &entity else { + panic!("expected Polyline2D, got {entity:?}"); }; assert!(polyline.flags.is_closed()); assert_eq!(polyline.vertices.len(), 1); diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 6ba318e8f..4bb0116ef 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3200,7 +3200,14 @@ mod tests { let mut create_script = case.create.replace("TMPDIR", &tmp); // Insert is update-only: the host makes a block and one insert, and the // "create" script only has to exist. - let update_only = create_script.contains("MAKEINSERT"); + let make_polyline = create_script.contains("MAKEPOLYLINE"); + if make_polyline { + // The legacy Polyline is update-only: the host makes one to edit. + let points = [(0.0, 0.0, 0.0), (10.0, 0.0, 2.0), (10.0, 5.0, 4.0)] + .map(|(x, y, z)| acadrust::types::Vector3::new(x, y, z)); + host.add_entity(EntityType::Polyline(acadrust::entities::Polyline::from_points(points.to_vec()))); + } + let update_only = create_script.contains("MAKEINSERT") || make_polyline; if update_only || create_script.contains("MAKEBLOCK") { let next = host.document().next_handle(); let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2)); @@ -4127,7 +4134,8 @@ mod tests { #[test] fn audit_python_polyline_lifecycle_over_real_ipc() { run_mesh_lifecycle(&MeshCase { - create: concat!("doc.create_entity('Polyline', vertices=[{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'location': {'x': 10.0, 'y': 5.0, 'z': 4.0}}])\n"), + create: "# MAKEPOLYLINE +", edit: concat!("e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", " e.vertices = [{'location': {'x': 0.0, 'y': 0.0, 'z': 0.0}}, {'location': {'x': 10.0, 'y': 0.0, 'z': 2.0}}, {'location': {'x': 10.0, 'y': 8.0, 'z': 6.0}}, {'location': {'x': 0.0, 'y': 8.0, 'z': 6.0}}]\n", " e.closed = True\n", "doc.selection = [e]\n"), rejects: &[ ("'vertices':[{'location':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2"), @@ -4208,7 +4216,7 @@ mod tests { #[test] fn audit_python_insert_lifecycle_over_real_ipc() { run_mesh_lifecycle(&MeshCase { - create: "# MAKEINSERT\n", + create: concat!("# MAKEBLOCK\n", "doc.create_entity('Insert', block_name='AUDITBLK', insert_point=P(1.0, 2.0, 0.0))\n"), edit: concat!( "e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", @@ -4224,7 +4232,7 @@ mod tests { ("'rotation':float('nan')", "finite"), ("'column_count':0", "greater than zero"), ("'insert_point':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), - ("'block_name':'OTHER'", "read-only"), + ("'block_name':'OTHER'", "does not exist"), ("'normal':{'x':0.0,'y':0.0,'z':0.0}", "nonzero"), ], is_kind: |entity| matches!(entity, EntityType::Insert(_)), @@ -4306,6 +4314,74 @@ mod tests { }); } + #[test] + fn audit_python_insert_creation_rules_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let mut records = std::collections::BTreeMap::new(); + for name in ["OUTER", "INNER"] { + let next = host.document().next_handle(); + let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2)); + let mut record = acadrust::tables::BlockRecord::new(name); + record.handle = record_handle; + record.block_entity_handle = block_handle; + record.block_end_handle = end_handle; + host.document_mut().block_records.add(record).unwrap(); + let mut block = acadrust::entities::Block::new(name, acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::Block(block)).unwrap(); + let mut end = acadrust::entities::BlockEnd::new(); + end.common.handle = end_handle; + end.common.owner_handle = record_handle; + host.document_mut().add_entity(EntityType::BlockEnd(end)).unwrap(); + records.insert(name, record_handle); + } + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let count = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::Insert(_))).count(); + let create = |name: &str, extra: &str| format!( + "PY_EVAL ocs.active_document.create_entity('Insert', block_name='{name}', insert_point={{'x':0.0,'y':0.0,'z':0.0}}{extra})"); + + // A valid insert of INNER into the drawing works. + dispatch(&mut host, &create("INNER", "")); + assert_eq!(count(&host), 1, "{}", last(&host)); + // Refused: unknown block, model space, bad scale, zero array count. + for (command, message) in [ + (create("MISSING", ""), "does not exist"), + (create("*Model_Space", ""), "model-space or paper-space"), + (create("INNER", ", x_scale=0.0"), "nonzero"), + (create("INNER", ", column_count=0"), "greater than zero"), + (create("", ""), "empty"), + ] { + dispatch(&mut host, &command); + assert_eq!(count(&host), 1, "{command}"); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + // A block cannot contain itself, directly or through another block. + let owner = |name: &str| format!(", owner_handle={}", records[name].value()); + dispatch(&mut host, &create("OUTER", &owner("OUTER"))); + assert_eq!(count(&host), 1); + assert!(last(&host).contains("contain itself"), "{}", last(&host)); + dispatch(&mut host, &create("INNER", &owner("OUTER"))); + assert_eq!(count(&host), 2, "{}", last(&host)); + dispatch(&mut host, &create("OUTER", &owner("INNER"))); + assert_eq!(count(&host), 2); + assert!(last(&host).contains("contain itself"), "{}", last(&host)); + // The legacy Polyline cannot be created and says what to use instead. + dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Polyline', vertices=[])"); + assert!(last(&host).contains("Polyline2D or Polyline3D"), "{}", last(&host)); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 226860d3b3af5e4b7e0f4b3f70641420d207310a Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 16:14:13 +0200 Subject: [PATCH 36/76] Refresh the Insert and Polyline wording in the design notes and plan Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 2 +- docs/plugin-host-model.md | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index fc74152dd..c9a104d9d 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -8,7 +8,7 @@ As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is v7. The Python feature has explicit writes for **37 of 43** canvas kinds: 36 -are creatable (the legacy `Polyline` is update-only) and `Insert` remains update-only. This is **not** 37 completed +are creatable (the legacy `Polyline` is update-only). This is **not** 37 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D, Polyline3D and Spline pass every gate that their format allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index c6cccb12c..3fbae22ad 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -50,10 +50,11 @@ The bundled first-party adapter under `plugins/opencad-python` currently maps these entity kinds to Python dictionaries: Point, Line, Circle, Arc, Ellipse, Polyline, Polyline2D, Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Insert, Tolerance, Shape, AttributeDefinition, -AttributeEntity, and Hatch. Insert is update-only: the block name is readable -but cannot be changed, and Python creation is withheld until block-table -references can be established safely. Attached attributes remain available in -the raw snapshot. The authoritative per-field mapping is +AttributeEntity, and Hatch. An Insert is created by naming an existing +ordinary block (the host refuses unknown blocks, model or paper space and +containment cycles); the block name cannot be changed afterwards. Attached +attributes stay with the AttributeEntity flow and remain in the raw snapshot. +The legacy Polyline is update-only: create a Polyline2D or Polyline3D. The authoritative per-field mapping is `plugins/opencad-python/entity_manifest.json`. The generated mapping excludes fields it cannot represent, including common color, line weight, transparency, Polyline3D smooth type, and MText background color. From f1813b3ebe2ec24c8fea58c3b1665e03f5e8454f Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 16:37:41 +0200 Subject: [PATCH 37/76] Draft cadcodec issues for the DXF reader gaps; fix Spline rational flag Documents six cadcodec reader/writer gaps with source references, canary tests and suggested fixes (nothing filed). Verifying them showed the Spline DXF weight loss was an OCS bug: the writer emits weights only for a rational spline, so the host now sets flags.rational from the weights. Spline passes every gate. Co-Authored-By: Claude Sonnet 5 --- docs/cadcodec-reader-gaps.md | 196 ++++++++++++++++++++++ docs/plugin-host-model-completion-plan.md | 3 +- docs/plugin-host-model-coverage-ledger.md | 4 +- src/app/plugin_host.rs | 9 +- 4 files changed, 207 insertions(+), 5 deletions(-) create mode 100644 docs/cadcodec-reader-gaps.md diff --git a/docs/cadcodec-reader-gaps.md b/docs/cadcodec-reader-gaps.md new file mode 100644 index 000000000..e8f5cc277 --- /dev/null +++ b/docs/cadcodec-reader-gaps.md @@ -0,0 +1,196 @@ +# cadcodec DXF reader/writer gaps found by the OCS Python host audit + +Draft issue reports for `HakanSeven12/cadcodec` (the `acadrust` crate). Nothing +here has been filed. Every finding was made against **cadcodec revision +`5b682ed`** (OCS pins `acadrust` to it) by saving a document with +`DxfWriter` or `DwgWriter`, reloading it, and comparing entity fields. + +Each report names an executable canary in OCS (`src/app/plugin_host.rs`). The +canaries assert today's wrong result, so they fail loudly once cadcodec is +fixed; flip the marked expectation when that happens. To reproduce, from the +OCS repository: + +```sh +STAGE="$PWD/work/bundled-plugins/opencad.python" +bash plugins/opencad-python/tools/stage-bundled.sh "$STAGE" --debug +cargo build --bin OpenCADStudio +OCS_TEST_PYTHON_PLUGIN="$STAGE/libopencad_python.dylib" \ +OCS_PLUGIN_RUNNER_EXE="$PWD/target/debug/OpenCADStudio" \ +cargo test --lib app::plugin_host::tests:: -- --test-threads=1 +``` + +| # | Area | Severity | Effect | +|---|---|---|---| +| 1 | `ATTDEF` reader | high | Most attribute-definition properties silently reset to defaults | +| 2 | Boolean group codes (290-299) | high | Booleans read through `as_i16()` are never applied | +| 3 | `UNDERLAY` rotation | high | Rotation is not converted back to radians; repeated saves compound it | +| 4 | `LEADER` | medium | Annotation link and three vectors are lost | +| 5 | `DRAWINGVIEW` / `SECTIONLINE` | medium | Typed entity not restored from the ENTITIES section | +| 6 | `TABLE` merged ranges | low | `merged_ranges` is empty after DXF reopen | + +--- + +## 1. ATTDEF reader handles eight group codes; the writer emits all of them + +**Where:** `src/io/dxf/reader/section_reader.rs`, `read_attdef` (around line +15647). The writer, `write_attdef` in `writer/section_writer.rs` (around line +4837), emits `7, 11, 41, 51, 70, 71, 72, 73, 74, 210` and `280` in addition to +the groups the reader consumes. + +**What happens:** the reader has arms for groups `1, 2, 3, 10/20/30, 40, 50, +280` and `101` only; everything else falls through to `_ =>`. After a DXF round +trip these fields reset to defaults: + +- text style name (7) +- alignment point (11/21/31) and both alignments (72, 74) +- width factor (41) and oblique angle (51) +- attribute flags (70): invisible, constant, verify, preset +- text generation flags (71) and field length (73) +- normal (210/220/230) and the position-lock state + +Tag, prompt, default value, insertion point, height and rotation survive. +Losing the attribute flags is the worst part: an invisible or constant +attribute becomes visible and editable after a DXF save. + +**Evidence:** `audit_python_attribute_definition_lifecycle_over_real_ipc` +compares every writable field through both formats. DWG round-trips the full +state; the DXF digest is pinned to the defaults (see the `BLOCKER` comment on +`expect_edited_dxf`). + +**Suggested fix:** add reader arms for the missing groups, using the same +conversions the writer applies (rotation and oblique angle are written in +degrees). `TEXT` and `ATTRIB` already read most of these groups and can share +the implementation. + +## 2. Boolean group codes are read with `as_i16()` and never applied + +**Where:** `src/io/dxf/reader/stream_reader.rs` defines +`DxfCodePair::as_i16()` as `Some` only for `CodePairValue::Int`. Groups 290-299 +are typed `Bool`, so `as_i16()` returns `None` for them and +`unwrap_or(0) != 0` yields `false`. The pattern appears in many arms of +`section_reader.rs`. + +**Confirmed impact (round-trip tested):** + +- `HELIX` group 290 (`handedness`): a clockwise helix reopens counter-clockwise. + The reader arm is `290 => { if let Some(v) = pair.as_i16() { ... } }` + (around line 12796). +- `LIGHT` groups 291, 292, 293 (`plot_glyph`, `use_attenuation_limits`, + `cast_shadows`; around lines 18154-18182), and 290 for the photometric web + file flag: `cast_shadows` reopens `false`. + +**Same pattern, not individually tested** (line numbers in `section_reader.rs`): +13833, 13955-13969 (point-cloud style flags); 18194 (`has_web_file`); +18431-18438 and 18493-18494 (sweep, loft and revolve option flags); and several +`if let Some(v) = pair.as_i16()` arms on 290/292 near lines 9815, 10096, 10478 +and 12861. Some other arms already use `as_bool()` correctly (for example lines +18376 and 18550), so the fix is mechanical. + +**Evidence:** `audit_python_helix_lifecycle_over_real_ipc` (canary +`expect_edited_dxf`) and `audit_python_light_lifecycle_over_real_ipc`. + +**Suggested fix:** read these groups with a helper that accepts either +representation, for example +`pair.as_bool().or_else(|| pair.as_i16().map(|v| v != 0))`, and audit every arm +whose code is in 290-299. Alternatively make `as_i16()` accept `Bool` values. + +## 3. `UNDERLAY` rotation is written in degrees and read back unconverted + +**Where:** `write_underlay` writes group 50 with `underlay.rotation.to_degrees()` +(around line 11038 of `writer/section_writer.rs`). `read_underlay` stores the +raw value: `50 => { if let Some(v) = pair.as_double() { underlay.rotation = v; } }` +(`reader/section_reader.rs`, around line 19470), with no `to_radians()`. + +**What happens:** a rotation of `0.5` rad is written as 28.6479 degrees and +read back as 28.6479 rad. Because the writer converts again on every save, the +error compounds: a second save-and-reopen yields 1641.4. This silently corrupts +existing drawings each time they pass through cadcodec. + +**Evidence:** `audit_python_underlay_lifecycle_over_real_ipc`; the canary +`expect_edited_dxf` / `expect_reedited_dxf` records 28.6 then 1641.4. + +**Suggested fix:** `underlay.rotation = v.to_radians()` in the reader. Check +that `DGN`/`DWF`/`PDF` underlays share the code path. + +## 4. `LEADER`: annotation link and vectors are lost in DXF + +**a. The writer never emits group 340.** `write_leader` writes 3, 71-76, 10, 210, +211, 212 and 213 but no associated-annotation handle, while the reader has a +`340 =>` arm that fills `annotation_handle` (around line 16619). A DXF save +therefore unlinks every leader from its text, tolerance or block annotation. + +**b. The coordinate mapping does not reassemble 211, 212 and 213.** +`GroupCodeValueType::coordinate_axis` in `src/io/dxf/group_code_value.rs` maps +X to `10..=18 | 110..=112 | 210 | 1010..=1013` (and the Y/Z equivalents), so +`add_coordinate` ignores the 211/221/231, 212/222/232 and 213/223/233 series. +`read_leader` uses a `PointReader` for `horizontal_direction`, `block_offset` +and `annotation_offset`, so all three reopen as their defaults even though the +writer emits them. (`normal`, group 210, works.) + +**c. `override_color` is not persisted** in DXF or DWG: a leader coloured by +`override_color` reopens `ByLayer`. Observed, cause not traced. + +**Evidence:** the canaries in `staged_python_leader_lifecycle_over_real_ipc` +(search for `BLOCKER`). DWG keeps the annotation link, offset and arrow flag. + +**Suggested fix:** write group 340 for a non-null `annotation_handle`; extend +`coordinate_axis`/`coordinate_group` to cover the 211-213 series (or read those +groups explicitly); decide where the override colour belongs (group 62/420 on +the leader). + +## 5. `DRAWINGVIEW` and `SECTIONLINE` are dispatched only inside blocks + +**Where:** `section_reader.rs` has two entity dispatch tables: one for block +contents (around lines 3241-3390) and one for the ENTITIES section +(`read_entities`, from line 3482). `"DRAWINGVIEW"` (line 3386) and +`"SECTIONLINE"` (line 3382) appear only in the first; the ENTITIES table has no +arm for them. `write_view_border_dxf` writes `DRAWINGVIEW` correctly. + +**What happens:** a `ViewBorder` saved to DXF in the drawing's entity list +reopens as a different, opaque entity kind instead of `ViewBorder`. DWG restores +the typed record. The same applies to `SectionSymbol`. + +**Evidence:** `audit_python_view_border_lifecycle_over_real_ipc` (canary +`expect_edited_dxf: "wrong kind"`). + +**Suggested fix:** add the two arms to the ENTITIES dispatch, calling +`read_view_border_dxf` and `read_section_symbol_dxf`. + +## 6. `TABLE` `merged_ranges` is empty after a DXF reopen + +**What happens:** the writer emits the merged-range list (`AcDbFormattedTableData`, +group 90 then 91-94 per range; near line 10216 of `writer/section_writer.rs`), +and `reader/section_reader/table_content.rs` (around lines 627-690) appears to +parse it, yet `Table::merged_ranges` is empty after a DXF reopen. The merge +itself survives through the origin cell's `merge_width` and `merge_height` +(groups 175 and 176), and DWG restores `merged_ranges` but not the per-cell +dimensions. + +**Impact:** low, because the merge is preserved in the cell fields; consumers +that read `merged_ranges` directly see nothing. Cause not traced; it may be that +the entity path does not call the table-content parser. + +**Evidence:** `staged_python_table_lifecycle_over_real_ipc` (the DXF branch +asserts an empty `merged_ranges` and marks it as a blocker). + +**Suggested fix:** rebuild `merged_ranges` from the cell merge dimensions after +reading, or make sure the `AcDbFormattedTableData` block is parsed for entities. + +--- + +## Observed but not filed + +These are format constraints or OCS-side issues, not reader bugs: + +- **DWG R2010+ does not store** a leader's `text_height`, `text_width` or + `hookline_enabled`, or a 3-D polyline's `elevation`; the writer stores them + only for the older versions that define them. +- **Entity-level `MultiLeader.text_height`** is not stored by either writer + (`context.text_height` is), so OCS exposes it read-only. +- **Legacy `Polyline` reopens as `Polyline3D`** from both formats and a 2-D + polyline reopens as `LwPolyline` from DXF. These look like deliberate + normalization; OCS made the legacy kind update-only and records the DXF + conversion. +- **Spline weights in DXF** were briefly suspected but are correct: the writer + emits group 41 only when `flags.rational` is set, so the host must set that + flag when weights are present (fixed in OCS). diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index c9a104d9d..64d11d655 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -9,7 +9,7 @@ As of 20 September 2026, the working branch is `plugin/host-model-api` at [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is v7. The Python feature has explicit writes for **37 of 43** canvas kinds: 36 are creatable (the legacy `Polyline` is update-only). This is **not** 37 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D, Polyline3D and Spline pass every gate that their format allows. +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current @@ -174,3 +174,4 @@ exact queue above and update statuses with evidence after each increment. - Audited the 17 pre-existing mapped kinds through the shared real-runner driver (`audit_python_*`). The audit found and fixed genuine validation gaps: Ellipse had none, Text and MText accepted a zero height, Arc accepted non-finite angles, Point and Line accepted infinite thickness, and most basic kinds had no creation-time validation. A shared `validate_basic_entity` now covers Point, Line, Circle, Arc, Ellipse, Text, MText, LwPolyline, Polyline, Polyline2D, Polyline3D and Spline, refusing only problems an edit introduces so already-invalid legacy values stay editable. Twelve of the 17 kinds now pass every gate (Point, Line, Circle, Arc, Ellipse, LwPolyline, Text, MText, Ray, XLine, Solid, Face3D). Four expose persistence findings pinned by canaries (Polyline reopens as Polyline3D; Polyline2D reopens as LwPolyline in DXF; Polyline3D loses elevation in DWG; Spline loses weights in DXF), and Insert is update-only with all applicable gates passing. The driver gained per-format DWG expectations and a host-made block fixture for Insert. - Audited `AttributeDefinition` against acadrust `5b682ed` with a full-field comparison through both formats. Everything passes in DWG. DXF keeps only tag, prompt, value, insertion point, height and rotation because the reader handles eight group codes; alignment, width factor, oblique angle, attribute flags, field length, generation flags, lock and text style reopen as defaults. The kind stays integration-tested and the gap is pinned by canary expectations; the fix belongs in cadcodec's `read_attdef`. The shared driver's block fixture now also serves `MAKEBLOCK` scripts. - Decided the two open audit questions. **Insert is creatable** by naming an existing block (existence, ordinary-block and no-cycle checks; attribute records stay with the AttributeEntity flow), which completes the kind. **Legacy Polyline is update-only** with a hint pointing scripts at Polyline2D and Polyline3D, because both formats convert it on save. The generator gained an `update_only_message` for such kinds, integer coordinates are accepted in Insert scales, and the driver gained `MAKEPOLYLINE` and `MAKEBLOCK` fixtures. +- Wrote up the cadcodec DXF reader gaps as draft issues in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md) (nothing filed): ATTDEF, boolean groups read through `as_i16()`, UNDERLAY rotation units, LEADER, DRAWINGVIEW/SECTIONLINE dispatch and TABLE merged ranges. Verifying them corrected an earlier attribution: the Spline "DXF drops weights" finding was an OCS bug (the writer emits weights only for a rational spline), now fixed by setting `flags.rational` from the weights, so Spline is complete. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 931abf8b9..13a1f79e5 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only legacy `Polyline`), **25 kinds are complete**, and 6 remain layer-only (one of them, SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only legacy `Polyline`), **26 kinds are complete**, and 6 remain layer-only (one of them, SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -15,7 +15,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Polyline2D | **Integration-tested / kind changes on DXF save** | Exercised: `vertices`, `closed`, `thickness`. Passes every gate in DWG. DXF reopens a 2-D polyline as an `LwPolyline` (vertices, closure and width survive, but the kind changes and `thickness` is not part of that record's digest here); pinned by a canary expectation. `audit_python_polyline2d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Polyline3D | **Integration-tested / blocked at W by the DWG format (elevation)** | Exercised: `vertices`, `elevation`. Passes every gate in DXF; DWG does not store a 3-D polyline's `elevation`, so it reopens as 0 (pinned by a canary). Vertices persist. `audit_python_polyline3d_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | LwPolyline | **Complete** | Exercised: `vertices` (with a bulge), `is_closed`, `constant_width`. Rejects fewer than 2 vertices, non-finite locations, bulges and widths, a negative constant width and a zero normal. `audit_python_lwpolyline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | -| Spline | **Integration-tested / blocked at W by acadrust (DXF weights)** | Exercised: `control_points`, `weights`. Validation covers degree, control-point count, knot count and order, weights and finite points. Passes every gate in DWG; the DXF reader does not restore `weights` (a rational spline reopens non-rational; pinned by a canary). `audit_python_spline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | +| Spline | **Complete** | Exercised: `control_points`, `weights`. Validation covers degree, control-point count, knot count and order, weights and finite points. The host sets `flags.rational` whenever weights are present, because the DXF writer emits group 41 only for a rational spline; the audit first showed weights vanishing in DXF and traced it to that missing flag (an OCS bug, not a cadcodec one), now fixed. `audit_python_spline_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P with DWG and DXF reopen, re-edit, atomic rollback and three-step undo/redo. | | Text | **Complete** | Exercised: `text`, `rotation`, `height`. Rejects a non-positive height (previously accepted), a non-positive width factor, non-finite placement and angles, and a zero normal. Style and alignment fields are mapped but not individually exercised. `audit_python_text_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | MText | **Complete** | Exercised: `text`, `rectangle_width`, `rotation`. Rejects a non-positive height (previously accepted), a negative width, non-finite placement, a non-positive line-spacing factor and a zero normal. Attachment, columns and background fields are mapped but not individually exercised. `audit_python_mtext_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | | Ray | **Complete** | Exercised: `base_point`, `direction`. Unit-direction and finite-point validation already existed; the audit added the full DWG and DXF reopen and re-edit gate. `audit_python_ray_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real runner) passes C/R/E/D/U/I/W/V/P: creation, a two-or-more-property transaction edit with the entity's common data, identity and selection preserved, canvas wires, invalid-input rejection with an atomic multi-entity rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. The audit added the shared `validate_basic_entity` (creation must be valid; an edit is refused only for problems it introduces, so already-invalid legacy values stay editable). | diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 4bb0116ef..e9fc9d03c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -158,6 +158,11 @@ impl<'a> HostSession<'a> { if let EntityType::Dimension(dimension) = entity { crate::entities::dimension::normalize_scripted_dimension(dimension); } + if let EntityType::Spline(spline) = entity { + // The DXF writer emits weights only for a rational spline, so the + // flag must follow the weights a script sets. + spline.flags.rational = !spline.weights.is_empty(); + } if let EntityType::Helix(new) = entity { let old = match old { Some(EntityType::Helix(old)) => Some(old), @@ -4208,8 +4213,8 @@ mod tests { expect_reedited: "deg2 cp3 y9.0 w[1.0, 2.0, 1.0]", expect_edited_dwg: "", expect_reedited_dwg: "", - expect_edited_dxf: "deg2 cp3 y8.0 w[]", - expect_reedited_dxf: "deg2 cp3 y9.0 w[]", + expect_edited_dxf: "", + expect_reedited_dxf: "", }); } From e583da83dbc387c88ff54ae4a207463bd306c941 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 16:43:25 +0200 Subject: [PATCH 38/76] Map Ole2Frame as update-only with the embedded object preserved Frame corners and the aspect lock are scriptable; the opaque storage and envelope are excluded and refused on edit. The audit embeds a real PNG through OCS's own builder and proves it survives DWG and DXF byte-for-byte. Python creation stays a follow-up. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 13 ++++ crates/ocs_plugin_api/src/entity_coverage.rs | 34 ++++++++++ docs/plugin-host-model-completion-plan.md | 7 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 29 ++++++++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 64 ++++++++++++++++++- 10 files changed, 149 insertions(+), 11 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 71f39498f..1d60b1952 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -793,6 +793,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("Helix", _) | ("RasterImage", _) | ("Wipeout", _) + | ("Ole2Frame", _) | ("Underlay", _) | ("Viewport", _) | ("ViewBorder", _) diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 549f1d767..62bcdae23 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -486,6 +486,13 @@ "shadow_map_softness", "photometric_mode", "photometric_data" + ], + "Ole2Frame": [ + "layer", + "upper_left_corner", + "lower_right_corner", + "lock_aspect", + "is_paper_space" ] }, "readable": { @@ -590,6 +597,12 @@ ], "Light": [ "class_version" + ], + "Ole2Frame": [ + "version", + "source_application", + "ole_object_type", + "dwg_mode" ] }, "aliases": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index fe30e534a..74c1056e1 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1223,6 +1223,28 @@ fn validate_basic_entity( } } +/// Ole2Frame: only the frame placement is scriptable; the embedded object +/// storage is never rewritten. +#[cfg(feature = "host")] +fn validate_ole_frame( + old: Option<&crate::host::acadrust::entities::Ole2Frame>, + new: &crate::host::acadrust::entities::Ole2Frame, +) -> Result<(), String> { + if old == Some(new) { + return Ok(()); + } + finite_vector("Ole2Frame.upper_left_corner", &new.upper_left_corner)?; + finite_vector("Ole2Frame.lower_right_corner", &new.lower_right_corner)?; + let (a, b) = (new.upper_left_corner, new.lower_right_corner); + if (a.x - b.x).abs() <= 1.0e-12 || (a.y - b.y).abs() <= 1.0e-12 { + return Err("Ole2Frame corners must span a nonzero width and height".into()); + } + if new.lock_aspect > 1 { + return Err("Ole2Frame.lock_aspect must be 0 or 1".into()); + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -1348,6 +1370,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() return Err("Insert array counts must be greater than zero".into()); } } + EntityType::Ole2Frame(value) => validate_ole_frame(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1716,6 +1739,12 @@ pub fn validate_entity_mutation( (EntityType::Viewport(old), EntityType::Viewport(new)) => validate_viewport(Some(old), new)?, (EntityType::ViewBorder(old), EntityType::ViewBorder(new)) => validate_view_border(Some(old), new)?, (EntityType::Light(old), EntityType::Light(new)) => validate_light(Some(old), new)?, + (EntityType::Ole2Frame(old), EntityType::Ole2Frame(new)) => { + validate_ole_frame(Some(old), new)?; + if old.storage != new.storage || old.envelope != new.envelope { + return Err("Ole2Frame embedded storage cannot change".into()); + } + } (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -2502,6 +2531,7 @@ mod tests { | "Viewport" | "ViewBorder" | "Light" + | "Ole2Frame" ) { assert!( @@ -2787,6 +2817,10 @@ mod tests { "Wipeout.clip_mode".to_owned(), "Wipeout.clip_type".to_owned(), "Wipeout.clip_boundary_vertices".to_owned(), + "Ole2Frame.upper_left_corner".to_owned(), + "Ole2Frame.lower_right_corner".to_owned(), + "Ole2Frame.lock_aspect".to_owned(), + "Ole2Frame.is_paper_space".to_owned(), "Underlay.underlay_type".to_owned(), "Underlay.definition_handle".to_owned(), "Underlay.insertion_point".to_owned(), diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 64d11d655..d8f370b87 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **37 of 43** canvas kinds: 36 -are creatable (the legacy `Polyline` is update-only). This is **not** 37 completed +v7. The Python feature has explicit writes for **38 of 43** canvas kinds: 36 +are creatable (the legacy `Polyline` and `Ole2Frame` are update-only). This is **not** 38 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 6 canvas kinds (SectionSymbol, Region, Body, Solid3D, Surface, Ole2Frame) have only `layer` and are recorded as blocked +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 5 canvas kinds (SectionSymbol, Region, Body, Solid3D, Surface) have only `layer` and are recorded as blocked writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -175,3 +175,4 @@ exact queue above and update statuses with evidence after each increment. - Audited `AttributeDefinition` against acadrust `5b682ed` with a full-field comparison through both formats. Everything passes in DWG. DXF keeps only tag, prompt, value, insertion point, height and rotation because the reader handles eight group codes; alignment, width factor, oblique angle, attribute flags, field length, generation flags, lock and text style reopen as defaults. The kind stays integration-tested and the gap is pinned by canary expectations; the fix belongs in cadcodec's `read_attdef`. The shared driver's block fixture now also serves `MAKEBLOCK` scripts. - Decided the two open audit questions. **Insert is creatable** by naming an existing block (existence, ordinary-block and no-cycle checks; attribute records stay with the AttributeEntity flow), which completes the kind. **Legacy Polyline is update-only** with a hint pointing scripts at Polyline2D and Polyline3D, because both formats convert it on save. The generator gained an `update_only_message` for such kinds, integer coordinates are accepted in Insert scales, and the driver gained `MAKEPOLYLINE` and `MAKEBLOCK` fixtures. - Wrote up the cadcodec DXF reader gaps as draft issues in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md) (nothing filed): ATTDEF, boolean groups read through `as_i16()`, UNDERLAY rotation units, LEADER, DRAWINGVIEW/SECTIONLINE dispatch and TABLE merged ranges. Verifying them corrected an earlier attribution: the Spline "DXF drops weights" finding was an OCS bug (the writer emits weights only for a rational spline), now fixed by setting `flags.rational` from the weights, so Spline is complete. +- Mapped `Ole2Frame` as update-only (queue item 21, previously blocked). OCS already builds real embedded frames (`build_embedded_ole`), which supplied the fixture the blocker was waiting for. Frame corners and the aspect lock are scriptable; the opaque storage and envelope are excluded and refused on edit. The audit proves the embedded picture survives DWG and DXF unchanged. Creation from Python remains a follow-up. The remaining blocked kinds are SectionSymbol, Region, Body, Solid3D and Surface. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 13a1f79e5..2389c4171 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **37 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only legacy `Polyline`), **26 kinds are complete**, and 6 remain layer-only (one of them, SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **38 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only legacy `Polyline` and `Ole2Frame`), **26 kinds are complete**, and 5 remain layer-only and blocked (SectionSymbol, Region, Body, Solid3D, Surface). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -48,7 +48,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Body | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | | Solid3D | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | | Surface | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | -| Ole2Frame | **Blocked** | Not mapped. A valid OLE frame carries a `StructuredStoragePayload` (a compound-file image of the embedded object) that a script cannot supply, so creation is impossible, and frame-geometry edits (`upper_left_corner`, `lower_right_corner`, `lock_aspect`) would be an update-only path that has no real embedded-object fixture to prove storage and aspect persistence through DWG and DXF. Adding it needs a fixture builder for a minimal valid OLE storage plus a proven round-trip of the payload; until then the kind keeps raw snapshot access and layer edits. | +| Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. | After each increment, update the row with test names, fixture paths, exact passing commands, the OCS commit hash, and any manual GUI result. Host and diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 3fbae22ad..375e3bb3c 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 37 geometry converters. Other writable properties +canvas kinds outside its 38 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -92,7 +92,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 37-kind generated schema it contains only handle, kind, and layer; +outside the 38-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index aac19fde3..bfe545089 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 37 mapped kinds. +the 38 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 5126e4464..5bca21be9 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -36,7 +36,8 @@ "Underlay", "Viewport", "ViewBorder", - "Light" + "Light", + "Ole2Frame" ], "manual_kinds": [ "Dimension" @@ -780,6 +781,32 @@ "required": true } } + }, + "Ole2Frame": { + "update_only": true, + "update_only_message": "Ole2Frame cannot be created from a script: its embedded object storage is opaque; existing frames can be moved, resized and aspect-locked, and the stored object is preserved untouched", + "fields": { + "storage": { + "exclude": true, + "_comment": "Opaque compound-file payload; snapshot-readable and preserved untouched." + }, + "envelope": { + "exclude": true, + "_comment": "Opaque frame envelope; preserved untouched." + }, + "version": { + "read_only": true + }, + "source_application": { + "read_only": true + }, + "ole_object_type": { + "read_only": true + }, + "dwg_mode": { + "read_only": true + } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index f6b565f56..1d348c8b9 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4780,7 +4780,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 37); + assert_eq!(emitted.len(), 38); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index e9fc9d03c..a1f7d6859 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -3212,7 +3212,28 @@ mod tests { .map(|(x, y, z)| acadrust::types::Vector3::new(x, y, z)); host.add_entity(EntityType::Polyline(acadrust::entities::Polyline::from_points(points.to_vec()))); } - let update_only = create_script.contains("MAKEINSERT") || make_polyline; + let make_ole = create_script.contains("MAKEOLE"); + if make_ole { + // An Ole2Frame is update-only: the host embeds a real picture. + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::from_pixel(4, 3, image::Rgba([10, 120, 200, 255])) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let picture = crate::io::ole_embed::EmbeddedImage { + bytes: png.into_inner(), + pixel_width: 4, + pixel_height: 3, + name: "audit.png".into(), + }; + crate::io::ole_embed::add_embedded_image( + host.document_mut(), + &picture, + acadrust::types::Vector3::new(10.0, 10.0, 0.0), + 20.0, + ) + .unwrap(); + } + let update_only = create_script.contains("MAKEINSERT") || make_polyline || make_ole; if update_only || create_script.contains("MAKEBLOCK") { let next = host.document().next_handle(); let (record_handle, block_handle, end_handle) = (Handle::new(next), Handle::new(next + 1), Handle::new(next + 2)); @@ -4387,6 +4408,47 @@ mod tests { assert!(last(&host).contains("Polyline2D or Polyline3D"), "{}", last(&host)); } + #[test] + fn audit_python_ole2frame_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: "# MAKEOLE\n", + edit: concat!( + "e = doc.entities[HANDLE]\n", + "with doc.transaction('Edit'):\n", + " e.upper_left_corner = (12.0, 40.0, 0.0)\n", + " e.lower_right_corner = (42.0, 10.0, 0.0)\n", + " e.lock_aspect = 1\n", + "doc.selection = [e]\n"), + rejects: &[ + ("'upper_left_corner':{'x':42.0,'y':40.0,'z':0.0}", "nonzero width and height"), + ("'lock_aspect':2", "0 or 1"), + ("'lower_right_corner':{'x':float('nan'),'y':0.0,'z':0.0}", "finite"), + ("'version':3", "read-only"), + ("'storage':{}", "outside the editable schema"), + ], + is_kind: |entity| matches!(entity, EntityType::Ole2Frame(_)), + digest: |entity| match entity { + EntityType::Ole2Frame(v) => { + let picture = match acadrust::entities::extract_presentation(&v.encoded_payload()) { + Some(acadrust::entities::OlePresentation::Raster(bytes)) => bytes.len(), + _ => 0, + }; + format!("ul{:.1},{:.1} lr{:.1},{:.1} lock{} picture{}", v.upper_left_corner.x, v.upper_left_corner.y, + v.lower_right_corner.x, v.lower_right_corner.y, v.lock_aspect, picture) + } + _ => "wrong kind".into(), + }, + reedit: |entity| if let EntityType::Ole2Frame(v) = entity { v.lower_right_corner.x = 50.0; }, + expect_created: "ul10.0,25.0 lr30.0,10.0 lock0 picture119", + expect_edited: "ul12.0,40.0 lr42.0,10.0 lock1 picture119", + expect_reedited: "ul12.0,40.0 lr50.0,10.0 lock1 picture119", + expect_edited_dxf: "", + expect_reedited_dxf: "", + expect_edited_dwg: "", + expect_reedited_dwg: "", + }); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 07fe7395713a617c8d5aab6fbfaaa928bff3148f Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:10:51 +0200 Subject: [PATCH 39/76] Investigate the cadkernel path for ACIS kinds; add spikes and findings OCS already creates, transforms and combines bodies through cadkernel, and solid_to_sat / kernel_acis_body verify each result lifts back losslessly. Two ignored spike tests measure volumes and DWG/DXF round trips. The blocker is an operation channel, not geometry; the note recommends an additive solid_operation host method and phases the work. Co-Authored-By: Claude Sonnet 5 --- docs/cadkernel-body-path.md | 137 ++++++++++++++++++++++ docs/plugin-host-model-completion-plan.md | 1 + docs/plugin-host-model-coverage-ledger.md | 8 +- src/app/plugin_host.rs | 109 +++++++++++++++++ 4 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 docs/cadkernel-body-path.md diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md new file mode 100644 index 000000000..87133ad05 --- /dev/null +++ b/docs/cadkernel-body-path.md @@ -0,0 +1,137 @@ +# Scripting ACIS bodies through cadkernel: findings and plan + +Investigation of the blocker on **Solid3D, Body, Region and Surface** +(`docs/plugin-host-model-coverage-ledger.md`). The blocker said a script can +neither produce a valid ACIS payload nor edit one, and that the handover +forbids rewriting an opaque payload without a proven lossless path. This note +records what OCS's own kernel path can already do, measured with two spike tests +(`spike_kernel_body_round_trip`, `spike_kernel_timings` in +`src/app/plugin_host.rs`, both `#[ignore]` because they are slow in a debug +build). Measured on cadcodec `5b682ed` / cadkernel `6f046af`, debug build. + +**Conclusion:** the blocker was overstated. OCS already owns a kernel-backed +create/transform/boolean pipeline whose output is verified lossless before it is +accepted. The missing piece is not geometry but an *operation channel* from a +script to that pipeline. Solid3D and Body are then straightforward; Region and +Surface need a little more; the one real cost is boolean speed. + +## What exists + +| Capability | Where | Notes | +|---|---|---| +| Primitives: box, wedge, cylinder, cone/frustum, elliptical cylinder, sphere, torus, pyramid, pyramid frustum | `scene/model/solid_model.rs` (`box_solid`, ...) | return a kernel `Body` | +| Transforms: place, turn about an axis, mirror, column-major 4x4 | `placed`, `turned`, `mirrored`, `by_matrix` | analytic surfaces move with their frames, no point rewriting | +| Booleans: union, subtract, intersect | `boolean_result` | refuses with a reason instead of returning a broken solid | +| Body to entity payload | `acis_export::solid_to_sat` | **self-verifying**: it lifts its own SAT text and SAB binary back and returns `None` unless both are lossless (`loss.is_empty()`) and validate | +| Entity payload to body | `solid3d_tess::kernel_body` / `kernel_region_body` / `kernel_surface_body` / `kernel_acis_body` | returns `None` when the lift is lossy or has more than one body | +| Regenerating derived data | `app/model_ops.rs::entity_with_boolean_body` | rewrites `acis_data`, `wires`, clears `silhouettes` and `history_handle` for Solid3D, Region and Surface | +| Volume, extent, edge wires | `volume`, `extent`, `edge_wires` | used as oracles below | + +`kernel_acis_body` is exactly the "proven lossless path" the handover asked for: +a payload is edited only if it lifts with no loss, otherwise it is left alone. + +## Spike results + +Everything below passed, with the kernel's own volume as the oracle. + +| Test | Result | +|---|---| +| Box 10x6x4 to Solid3D | volume 240, 12 edge wires, lifts back | +| DWG save/reopen | lifts back, volume 240, 12 wires kept. `acis_data` changes from SAT text to **SAB binary** (same solid, different bytes) | +| DXF save/reopen | lifts back, volume 240. Cached `wires` are dropped (0) and are regenerated on demand; silhouettes were already empty | +| Translate by (5,5,5) | volume 240, extent shifted exactly, lifts back | +| Rotate 45 degrees about Z | volume 240, extent -5.657..5.657, lifts back | +| Mirror in X | volume 240, lifts back | +| Box union cylinder | volume 315.277, 16 wires, lifts back | +| Box subtract cylinder | volume 189.815, 14 wires, lifts back | +| Box intersect cylinder | volume 50.185, 2 wires, lifts back | +| Sphere, torus, wedge, pyramid | volumes 112.696, 98.415, 12.000, 28.532; all lift back | +| Same payload in a `Body` entity | lifts back; still lifts back after DWG and after DXF | + +Timings (debug build): primitive, transform, `solid_to_sat` and `edge_wires` are +effectively instant (under 0.05 s). **Each boolean took about 23 s**, most likely +a debug-build artifact for a kernel doing heavy numeric work, but it has not been +measured in release and must be before booleans are exposed. + +Two things to understand about "lossless": + +- DWG rewrites SAT text as SAB binary. The solid is identical (same volume, + topology, faces) but the bytes are not, so the honest oracle is "lifts back with + no loss and the same volume", not byte equality. An untouched body that a + script never edits is written exactly as OCS writes any other body. +- DXF does not carry the cached wires; they are derived data. + +## Design + +The obstacle is the channel. The generic entity path moves an acadrust +`EntityType` from the plugin to the host; the plugin has no kernel and must never +see or rewrite ACIS bytes. Three ways to give a script the operations: + +**A. Host-side normalization with a request carrier (no ABI change).** A +hand-written converter (as for Dimension) turns `create_entity('Solid3D', +primitive='box', ...)` into a Solid3D that carries the request in an otherwise +unused field, and the host's `normalize_scripted_entity` replaces it with the real +payload, exactly as it already does for MLine geometry, Helix splines and image +definitions. It works inside today's v7 protocol, but the carrier field is a hack. + +**B. New host operation (recommended).** Add one additive operation to `HostApi` +and the v4 IPC protocol, for example `solid_operation(op) -> Result`, +with `op` one of: create primitive, transform, boolean, and (later) region from +curves. The host runs it through the pipeline above inside an undo step and +returns the new or updated handle. Because it is a new method with a default +"unsupported" implementation it is backward compatible; bump the minor/negotiate +by capability rather than the major version. Python side: + +```python +box = doc.solids.box(center=(0, 0, 0), size=(10, 6, 4)) +cyl = doc.solids.cylinder(base=(0, 0, -3), radius=2, height=10) +hole = box.subtract(cyl) # new Solid3D; operands untouched +box.transform(translate=(5, 5, 5)) # in-place, transactional, undoable +``` + +**C. Do nothing but layer edits** (the current state). + +Recommended: **B**, with `solid_operation` covering create, transform and boolean. +Every operation goes through `solid_to_sat` (self-verifying) and the kernel's own +refusal path, so a failure is an error before any mutation, and an untouched +payload is never rewritten. In-place edits of an existing entity are gated by +`kernel_acis_body` returning `Some`: a payload the kernel cannot lift losslessly +is refused with a clear message instead of being rewritten. + +## Per-kind feasibility + +| Kind | Create | Edit | Notes | +|---|---|---|---| +| **Solid3D** | 9 primitives, boolean of two solids | transform, boolean | Straightforward. Regenerate `wires`; clear `silhouettes`; `history_handle` is cleared by `entity_with_boolean_body`, so a history-carrying solid needs a decision (refuse, or accept losing parametric history). | +| **Body** | same payload container | transform | Same pipeline, `kernel_acis_body` works on a `Body` payload (spike). A true non-solid (sheet or wire) body may not lift; those stay layer-only. | +| **Region** | from closed curve entities (the REGION command's `add_region_model`) | transform | Needs a "region from these curve handles" operation and planar-face construction. | +| **Surface** | plane, extruded, revolved from a profile | transform | The most involved: surface kinds carry `surface_data` besides `acis_data`, and only `Plane` keeps its kind on regeneration (`SurfaceKind::Generic` otherwise). | + +## Risks and open questions + +1. **Boolean speed.** About 23 s each in a debug build. Time them in release + before deciding whether they can run on the host thread (the GUI BOOLEAN + command has the same cost) or need a background task. Everything else is + instant. +2. **Interoperability cannot be verified here.** `solid_to_sat` is verified by + OCS's own lifter. Whether AutoCAD accepts the SAT/SAB that OCS writes is not + testable without AutoCAD, so completion should be scoped as "OCS round trip", + as for RasterImage. +3. **Parametric history.** Regenerating a payload clears `history_handle`. +4. **Frames.** Some solids carry a body-to-world transform in the SAT + (`body_transform` in `kernel_acis_body`); the lift already applies it, and the + spike's translated/rotated bodies confirm it, but a foreign DWG with a + scaled or sheared body transform should be added as a fixture. +5. **API surface.** `solid_operation` is a protocol addition; decide whether it is + one generic operation enum or separate methods before starting. + +## Suggested phases + +1. `solid_operation` protocol and host executor for **create primitive** and + **transform** on Solid3D, with the full lifecycle test through the real runner + (volume oracle, DWG and DXF reopen, undo, refusal of a lossy payload). +2. **Booleans** (after release-build timing) and the **Body** container. +3. **Region** from curve handles. +4. **Surface** (plane first). + +Phase 1 alone would move Solid3D from blocked to complete-in-OCS. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index d8f370b87..104d6b4c5 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -176,3 +176,4 @@ exact queue above and update statuses with evidence after each increment. - Decided the two open audit questions. **Insert is creatable** by naming an existing block (existence, ordinary-block and no-cycle checks; attribute records stay with the AttributeEntity flow), which completes the kind. **Legacy Polyline is update-only** with a hint pointing scripts at Polyline2D and Polyline3D, because both formats convert it on save. The generator gained an `update_only_message` for such kinds, integer coordinates are accepted in Insert scales, and the driver gained `MAKEPOLYLINE` and `MAKEBLOCK` fixtures. - Wrote up the cadcodec DXF reader gaps as draft issues in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md) (nothing filed): ATTDEF, boolean groups read through `as_i16()`, UNDERLAY rotation units, LEADER, DRAWINGVIEW/SECTIONLINE dispatch and TABLE merged ranges. Verifying them corrected an earlier attribution: the Spline "DXF drops weights" finding was an OCS bug (the writer emits weights only for a rational spline), now fixed by setting `flags.rational` from the weights, so Spline is complete. - Mapped `Ole2Frame` as update-only (queue item 21, previously blocked). OCS already builds real embedded frames (`build_embedded_ole`), which supplied the fixture the blocker was waiting for. Frame corners and the aspect lock are scriptable; the opaque storage and envelope are excluded and refused on edit. The audit proves the embedded picture survives DWG and DXF unchanged. Creation from Python remains a follow-up. The remaining blocked kinds are SectionSymbol, Region, Body, Solid3D and Surface. +- Investigated the cadkernel path for the ACIS kinds ([cadkernel-body-path.md](cadkernel-body-path.md)). The earlier "blocked" reasoning was overstated: OCS already builds primitives, transforms bodies and runs booleans through cadkernel, and `solid_to_sat` plus `kernel_acis_body` verify each result lifts back with no loss. Two `#[ignore]` spike tests measured exact volumes and DWG and DXF round trips for a box, moved, rotated and mirrored copies, three booleans, four other primitives and a `Body` entity. The real gap is an operation channel from a script to that pipeline; the note recommends an additive `solid_operation` host method and a phased plan (Solid3D create and transform, then booleans and Body, then Region, then Surface). Booleans took about 23 s each in a debug build and need release timing first. Region, Body, Solid3D and Surface move from blocked to unblocked-by-design; SectionSymbol stays blocked. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 2389c4171..4a46f9c8c 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -44,10 +44,10 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | | SectionSymbol | **Blocked** | Not mapped. A valid section mark needs a `SectionViewStyle` and a `ViewRep` view-representation object that a script cannot create through the host API, and the entity stores raw point counts and flag words (`raw_point_count_90`, `raw_flags_90`, `raw_point_record_count`, `raw_view_symbol_70`) whose relationship to `points` is undocumented in acadrust and is written back verbatim by both writers, so the host cannot keep them consistent after a scripted edit without guessing. Adding the kind needs (1) host-side creation or lookup of a section style and view representation, and (2) a confirmed derivation of the raw counts from a real DWG produced by another CAD application. The kind keeps raw snapshot access and layer edits. | | Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. | -| Region | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | -| Body | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | -| Solid3D | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | -| Surface | **Blocked** | Not mapped. The entity is an opaque ACIS payload (`acis_data` SAT/SAB text or binary) plus derived `wires`, `silhouettes` and, for solids, a history record. A script can neither produce a valid body nor edit one, because every geometric change needs the CAD kernel to rewrite the payload, and the handover forbids deserializing and rewriting opaque ACIS data without a proven lossless path. The only lossless edit today is the layer change the document model already supports. Adding this kind needs a host-side, kernel-backed API (create a primitive, or apply a transform, then regenerate `acis_data` with its wires and silhouettes) with a DWG and DXF round-trip that preserves the payload and history byte-for-byte for untouched bodies. | +| Region | **Blocked, unblocked by design** | Not mapped yet. Transform uses the same pipeline; creation needs a "region from these curve handles" operation built on the REGION command's `add_region_model`. See [cadkernel-body-path.md](cadkernel-body-path.md). | +| Body | **Blocked, unblocked by design** | Not mapped yet. Same pipeline as Solid3D: `kernel_acis_body` lifts a `Body` payload and the spike round-tripped it through DWG and DXF. A genuinely non-solid (sheet or wire) body may not lift and would stay layer-only. See [cadkernel-body-path.md](cadkernel-body-path.md). | +| Solid3D | **Blocked, unblocked by design** | Not mapped yet. The 2026-09-20 kernel investigation ([cadkernel-body-path.md](cadkernel-body-path.md)) shows OCS already creates the payload (nine primitives), transforms it and combines bodies through cadkernel, and that `solid_to_sat` and `kernel_acis_body` verify every result lifts back with no loss; spikes confirmed exact volumes and DWG and DXF round trips. What is missing is a script-to-host operation channel (recommended: an additive `solid_operation` host method covering create-primitive, transform and boolean), not geometry. Open items: boolean speed (about 23 s in a debug build, unmeasured in release), history-carrying solids, and AutoCAD acceptance, which cannot be tested here. | +| Surface | **Blocked, unblocked by design** | Not mapped yet, and the most involved: a surface carries `surface_data` beside `acis_data`, and only a plane keeps its kind when regenerated. Plan: planes first, then extruded and revolved from a profile. See [cadkernel-body-path.md](cadkernel-body-path.md). | | Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. | After each increment, update the row with test names, fixture paths, exact diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index a1f7d6859..0fb2bae30 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -4449,6 +4449,115 @@ mod tests { }); } + /// Evidence for docs/cadkernel-body-path.md: primitive, transform and + /// boolean bodies become Solid3D/Body payloads that lift back losslessly + /// and survive DWG and DXF. Slow in a debug build (booleans take ~23 s). + #[test] + #[ignore = "kernel spike; slow in debug builds"] + fn spike_kernel_body_round_trip() { + use crate::scene::model::solid_model as sm; + use crate::scene::convert::solid3d_tess::{kernel_acis_body, kernel_body}; + let make = |body: &cadkernel::brep::Body| -> acadrust::entities::Solid3D { + let sat = crate::scene::convert::acis_export::solid_to_sat(body).expect("solid_to_sat"); + let mut solid = acadrust::entities::Solid3D::new(); + solid.wires = sm::edge_wires(body); + solid.set_sat_document(&sat); + solid + }; + let report = |label: &str, solid: &acadrust::entities::Solid3D| { + let body = kernel_body(solid); + eprintln!("SPIKE {label}: lift={} volume={:?} wires={} sat_bytes={} binary={}", + body.is_some(), body.as_ref().map(sm::volume), solid.wires.len(), + solid.acis_data.sat_data.len(), solid.acis_data.is_binary); + body + }; + let boxed = sm::box_solid([0.0, 0.0, 0.0], 10.0, 6.0, 4.0).unwrap(); + let mut doc = acadrust::CadDocument::new(); + let solid = make(&boxed); + report("created box", &solid); + let handle = doc.add_entity(EntityType::Solid3D(solid.clone())).unwrap(); + + // Round trip both formats and compare payloads. + let dwg = crate::io::load_bytes("s.dwg", acadrust::DwgWriter::write_to_vec(&doc).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("s.dxf", acadrust::DxfWriter::new(&doc).write_to_vec().unwrap()).unwrap(); + for (label, reopened) in [("DWG", &dwg), ("DXF", &dxf)] { + let Some(EntityType::Solid3D(back)) = reopened.get_entity(handle) else { panic!("{label} lost the solid") }; + report(&format!("{label} reopen"), back); + eprintln!("SPIKE {label}: acis_data equal={} wires equal={} silhouettes {}", back.acis_data == solid.acis_data, + back.wires.len() == solid.wires.len(), back.silhouettes.len()); + } + + // Transforms: translate, rotate, mirror, matrix. + let moved = sm::by_matrix(&boxed, [1.,0.,0.,0., 0.,1.,0.,0., 0.,0.,1.,0., 5.,5.,5.,1.]).unwrap(); + let turned = sm::turned(&boxed, 2, std::f64::consts::FRAC_PI_4, [0.0, 0.0, 0.0]).unwrap(); + let mirrored = sm::mirrored(&boxed, 0, [0.0, 0.0, 0.0]).unwrap(); + for (label, body) in [("moved", &moved), ("turned", &turned), ("mirrored", &mirrored)] { + let solid = make(body); + let extent = sm::extent(body); + eprintln!("SPIKE {label}: volume={:.3} extent={:?} lifts_back={}", sm::volume(body), extent, kernel_body(&solid).is_some()); + } + + // Booleans and primitives. + let cyl = sm::cylinder_solid([0.0, 0.0, -3.0], 2.0, 10.0).unwrap(); + for (label, op) in [("union", sm::Bool::Union), ("subtract", sm::Bool::Subtract), ("intersect", sm::Bool::Intersect)] { + match sm::boolean_result(op, &boxed, &cyl) { + Ok(result) => { let solid = make(&result); eprintln!("SPIKE box {label} cylinder: volume={:.3} faces_wires={} lifts_back={}", sm::volume(&result), solid.wires.len(), kernel_body(&solid).is_some()); } + Err(snag) => eprintln!("SPIKE box {label} cylinder: refused {snag:?}"), + } + } + for (label, body) in [ + ("sphere", sm::sphere_solid([0.0; 3], 3.0)), ("torus", sm::torus_solid([0.0; 3], 5.0, 1.0)), + ("wedge", sm::wedge_solid([0.0; 3], 4.0, 3.0, 2.0)), ("pyramid", sm::pyramid_solid([0.0; 3], 3.0, 4.0, 5)), + ] { + match body { + Some(body) => { let solid = make(&body); eprintln!("SPIKE {label}: volume={:.3} lifts_back={}", sm::volume(&body), kernel_body(&solid).is_some()); } + None => eprintln!("SPIKE {label}: kernel returned None"), + } + } + + // The same payload inside a Body entity and a Region-like container. + let mut body_entity = acadrust::entities::Body::new(); + body_entity.set_sat_document(&crate::scene::convert::acis_export::solid_to_sat(&boxed).unwrap()); + let lifted = kernel_acis_body(&body_entity.acis_data); + eprintln!("SPIKE Body entity: lift={} volume={:?}", lifted.is_some(), lifted.as_ref().map(sm::volume)); + let bh = doc.add_entity(EntityType::Body(body_entity)).unwrap(); + let dwg = crate::io::load_bytes("b.dwg", acadrust::DwgWriter::write_to_vec(&doc).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("b.dxf", acadrust::DxfWriter::new(&doc).write_to_vec().unwrap()).unwrap(); + for (label, reopened) in [("DWG", &dwg), ("DXF", &dxf)] { + match reopened.get_entity(bh) { + Some(EntityType::Body(back)) => eprintln!("SPIKE Body {label}: lift={}", kernel_acis_body(&back.acis_data).is_some()), + other => eprintln!("SPIKE Body {label}: came back as {:?}", other.map(|e| format!("{e:?}").chars().take(30).collect::())), + } + } + } + + /// Per-operation kernel timings for docs/cadkernel-body-path.md. + #[test] + #[ignore = "kernel spike; slow in debug builds"] + fn spike_kernel_timings() { + use crate::scene::model::solid_model as sm; + use std::time::Instant; + let time = |label: &str, f: &mut dyn FnMut() -> String| { + let start = Instant::now(); + let note = f(); + eprintln!("TIMING {label}: {:.2}s {note}", start.elapsed().as_secs_f64()); + }; + let boxed = sm::box_solid([0.0; 3], 10.0, 6.0, 4.0).unwrap(); + let cyl = sm::cylinder_solid([0.0, 0.0, -3.0], 2.0, 10.0).unwrap(); + time("box primitive", &mut || format!("{}", sm::box_solid([0.0; 3], 10.0, 6.0, 4.0).is_some())); + time("sphere primitive", &mut || format!("{}", sm::sphere_solid([0.0; 3], 3.0).is_some())); + time("solid_to_sat(box)", &mut || format!("{}", crate::scene::convert::acis_export::solid_to_sat(&boxed).is_some())); + time("edge_wires(box)", &mut || format!("{}", sm::edge_wires(&boxed).len())); + time("transform (matrix)", &mut || format!("{}", sm::by_matrix(&boxed, [1.,0.,0.,0., 0.,1.,0.,0., 0.,0.,1.,0., 5.,5.,5.,1.]).is_some())); + time("union", &mut || format!("{}", sm::boolean_result(sm::Bool::Union, &boxed, &cyl).is_ok())); + time("subtract", &mut || format!("{}", sm::boolean_result(sm::Bool::Subtract, &boxed, &cyl).is_ok())); + time("intersect", &mut || format!("{}", sm::boolean_result(sm::Bool::Intersect, &boxed, &cyl).is_ok())); + let result = sm::boolean_result(sm::Bool::Union, &boxed, &cyl).unwrap(); + time("solid_to_sat(union)", &mut || format!("{}", crate::scene::convert::acis_export::solid_to_sat(&result).is_some())); + time("edge_wires(union)", &mut || format!("{}", sm::edge_wires(&result).len())); + time("volume(union)", &mut || format!("{:.1}", sm::volume(&result))); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 04cd9e462daa6b76d389439910b1db7667ed3c70 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:29:06 +0200 Subject: [PATCH 40/76] Add kernel solid operations and complete Solid3D (create and rigid move) An additive HostApi::solid_operation builds cadkernel primitives and applies rigid transforms, verifying each result lifts back losslessly and refusing a payload it cannot lift. Exposed as doc.solids in Python; Solid3D, Body and Region are mapped read-only with the opaque payload preserved. The lifecycle test passes through the real runner with analytic volumes and exact extents as oracles, DWG and DXF reopen with re-edit, and undo restores exactly. Co-Authored-By: Claude Sonnet 5 --- crates/.DS_Store | Bin 0 -> 8196 bytes .../entity_coverage_policy.json | 24 ++ crates/ocs_plugin_api/src/entity_coverage.rs | 3 + crates/ocs_plugin_api/src/host.rs | 34 ++ crates/ocs_plugin_api/src/ipc/client.rs | 12 + crates/ocs_plugin_api/src/ipc/protocol.rs | 3 + crates/ocs_plugin_api/src/ipc/server.rs | 1 + crates/ocs_plugin_api/src/ipc/v4/client.rs | 12 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 21 ++ docs/cadkernel-body-path.md | 2 + docs/plugin-host-model-completion-plan.md | 9 +- docs/plugin-host-model-coverage-ledger.md | 8 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 86 ++++- plugins/opencad-python/src/document_model.py | 55 +++ plugins/opencad-python/src/ocs_module.rs | 110 +++++- src/app/plugin_host.rs | 344 ++++++++++++++++++ 18 files changed, 717 insertions(+), 13 deletions(-) create mode 100644 crates/.DS_Store diff --git a/crates/.DS_Store b/crates/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..bc57e5c5760aead09d636e1bf44001717d43cd9f GIT binary patch literal 8196 zcmeHMO-~d-6s*RLZZtuR95~Lw#Ax7vCyxVJZzP&<cpx0P zdhu8A2RL)adi^@?bY_8pL@?oX(p@v%UE5RjW~cYf0&vTt-Xbsupv@{c^MuWWBI8nP zWh(T{5*_)#7i?jK0W$2>W?P3jU=ElA=72e14*U-eaL<;~zF^q139EMl)^MwOb%%11&o9?Qodvb+#kyV>&6!nwuh!nK?7Z~) z8m>FY$2zaij`gOZcN#g?@fja6WW+{{B-Qb(%k%9><-@h(tL9g=&77Qn{jRaQbd9Q_ zrkqH}qYtr;g7;lU1qGGuW4C@6zBq_0QctInBH&JZx_=uVsbF2E^PvKjL(>3^GtvBJvx)X_?6mHLc2PP)rJPlYXa)ib9 ze{X{|=D?jf@IY6dll=eP#qa;`?D}kBbHE(<_YSyLXSK7;b#!a4FDSWo!up9-ip(n& lJ%x=-k$#_!6aW5U$m1l8r2$Jtj }, + /// 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] }, +} + /// Value of a host-managed drafting or document setting exposed to plugins. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum HostSettingValue { @@ -620,6 +647,13 @@ pub trait HostApi { 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 { + Err("solid operations are not supported by this host".to_owned()) + } } /// Simplified, read-only entity kind exposed by [`DocumentReader`]. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 744232162..aa5048284 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -470,6 +470,18 @@ impl HostApi for PluginHostApi { } } + fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result { + 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 set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) { Ok(PluginResponse::SelectionResult(result)) => result, diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index d6bd60a59..70a5b72b6 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -149,6 +149,8 @@ pub enum PluginRequest { /// V7: synchronous selection read/write for the dispatch tab. GetSelection, SetSelection { handles: Vec }, + /// V7 (additive): kernel-backed solid create or transform. + SolidOperation { operation: crate::host::SolidOperation }, } /// Responses the host sends back for `PluginRequest`. @@ -180,6 +182,7 @@ pub enum PluginResponse { EntityTransactionResult(Result<(), String>), Selection(Vec), SelectionResult(Result<(), String>), + SolidResult(Result), } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index ee96ceac9..b20262b02 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -107,5 +107,6 @@ pub fn handle_plugin_request( ), GetSelection => PluginResponse::Selection(host.selection()), SetSelection { handles } => PluginResponse::SelectionResult(host.set_selection(&handles)), + SolidOperation { operation } => PluginResponse::SolidResult(host.solid_operation(operation)), } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 1de1631ae..cac660388 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -784,6 +784,18 @@ impl HostApi for V4PluginHostApi { } } + fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result { + 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 set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) { Ok(PluginResponse::SelectionResult(result)) => result, diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 4b6ddd583..d9c3e8bae 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -105,6 +105,27 @@ mod tests { assert_eq!(decoded, changed); } + #[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] }, + ] { + let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap(); + assert!(matches!(bincode::deserialize::(&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::(&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)] }; diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md index 87133ad05..ee70be7c1 100644 --- a/docs/cadkernel-body-path.md +++ b/docs/cadkernel-body-path.md @@ -135,3 +135,5 @@ is refused with a clear message instead of being rewritten. 4. **Surface** (plane first). Phase 1 alone would move Solid3D from blocked to complete-in-OCS. + +**Status (2026-09-20): phase 1 is done.** `HostApi::solid_operation` (create primitive and rigid transform), the `doc.solids` Python API and `audit_python_solid3d_lifecycle_over_real_ipc` are in; Solid3D is complete, Body moves through the same channel. Design B was implemented as recommended. Booleans, Region creation and Surface remain. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 104d6b4c5..8adc7eab1 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **38 of 43** canvas kinds: 36 -are creatable (the legacy `Polyline` and `Ole2Frame` are update-only). This is **not** 38 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid and Face3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. +v7. The Python feature has explicit writes for **41 of 43** canvas kinds: 37 +are creatable (the legacy `Polyline`, `Ole2Frame`, `Body` and `Region` are update-only). This is **not** 41 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D and Solid3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The other 5 canvas kinds (SectionSymbol, Region, Body, Solid3D, Surface) have only `layer` and are recorded as blocked +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The remaining two canvas kinds have only `layer`: SectionSymbol (blocked) and Surface (unblocked by design) writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -177,3 +177,4 @@ exact queue above and update statuses with evidence after each increment. - Wrote up the cadcodec DXF reader gaps as draft issues in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md) (nothing filed): ATTDEF, boolean groups read through `as_i16()`, UNDERLAY rotation units, LEADER, DRAWINGVIEW/SECTIONLINE dispatch and TABLE merged ranges. Verifying them corrected an earlier attribution: the Spline "DXF drops weights" finding was an OCS bug (the writer emits weights only for a rational spline), now fixed by setting `flags.rational` from the weights, so Spline is complete. - Mapped `Ole2Frame` as update-only (queue item 21, previously blocked). OCS already builds real embedded frames (`build_embedded_ole`), which supplied the fixture the blocker was waiting for. Frame corners and the aspect lock are scriptable; the opaque storage and envelope are excluded and refused on edit. The audit proves the embedded picture survives DWG and DXF unchanged. Creation from Python remains a follow-up. The remaining blocked kinds are SectionSymbol, Region, Body, Solid3D and Surface. - Investigated the cadkernel path for the ACIS kinds ([cadkernel-body-path.md](cadkernel-body-path.md)). The earlier "blocked" reasoning was overstated: OCS already builds primitives, transforms bodies and runs booleans through cadkernel, and `solid_to_sat` plus `kernel_acis_body` verify each result lifts back with no loss. Two `#[ignore]` spike tests measured exact volumes and DWG and DXF round trips for a box, moved, rotated and mirrored copies, three booleans, four other primitives and a `Body` entity. The real gap is an operation channel from a script to that pipeline; the note recommends an additive `solid_operation` host method and a phased plan (Solid3D create and transform, then booleans and Body, then Region, then Surface). Booleans took about 23 s each in a debug build and need release timing first. Region, Body, Solid3D and Surface move from blocked to unblocked-by-design; SectionSymbol stays blocked. +- Built the cadkernel operation channel and completed `Solid3D` (create and rigid move). `HostApi::solid_operation` is additive (default "unsupported"), so older hosts and plugins are unaffected; it travels as `PluginRequest::SolidOperation` and `PluginResponse::SolidResult`, and the host records its own undo step only after validation so a refused operation leaves nothing behind. The Python surface is `doc.solids.*`. Every result is verified lossless by the kernel path before it is accepted and a payload that will not lift is refused untouched. `Body` moves through the same channel and `Region` is accepted but untested. Booleans, Region creation from curves and Surface remain, in that order; booleans wait on release-build timing. Solid3D, Body and Region are now mapped read-only with the opaque payload excluded and preserved. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 4a46f9c8c..59d8363b5 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **38 of 43 canvas kinds have explicit geometry writes** (36 creatable, plus update-only legacy `Polyline` and `Ole2Frame`), **26 kinds are complete**, and 5 remain layer-only and blocked (SectionSymbol, Region, Body, Solid3D, Surface). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **41 of 43 canvas kinds have explicit geometry writes** (37 creatable, plus update-only legacy `Polyline`, `Ole2Frame`, `Body` and `Region`), **27 kinds are complete**, and 2 remain layer-only (SectionSymbol blocked, Surface unblocked by design). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -44,9 +44,9 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | | SectionSymbol | **Blocked** | Not mapped. A valid section mark needs a `SectionViewStyle` and a `ViewRep` view-representation object that a script cannot create through the host API, and the entity stores raw point counts and flag words (`raw_point_count_90`, `raw_flags_90`, `raw_point_record_count`, `raw_view_symbol_70`) whose relationship to `points` is undocumented in acadrust and is written back verbatim by both writers, so the host cannot keep them consistent after a scripted edit without guessing. Adding the kind needs (1) host-side creation or lookup of a section style and view representation, and (2) a confirmed derivation of the raw counts from a real DWG produced by another CAD application. The kind keeps raw snapshot access and layer edits. | | Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. | -| Region | **Blocked, unblocked by design** | Not mapped yet. Transform uses the same pipeline; creation needs a "region from these curve handles" operation built on the REGION command's `add_region_model`. See [cadkernel-body-path.md](cadkernel-body-path.md). | -| Body | **Blocked, unblocked by design** | Not mapped yet. Same pipeline as Solid3D: `kernel_acis_body` lifts a `Body` payload and the spike round-tripped it through DWG and DXF. A genuinely non-solid (sheet or wire) body may not lift and would stay layer-only. See [cadkernel-body-path.md](cadkernel-body-path.md). | -| Solid3D | **Blocked, unblocked by design** | Not mapped yet. The 2026-09-20 kernel investigation ([cadkernel-body-path.md](cadkernel-body-path.md)) shows OCS already creates the payload (nine primitives), transforms it and combines bodies through cadkernel, and that `solid_to_sat` and `kernel_acis_body` verify every result lifts back with no loss; spikes confirmed exact volumes and DWG and DXF round trips. What is missing is a script-to-host operation channel (recommended: an additive `solid_operation` host method covering create-primitive, transform and boolean), not geometry. Open items: boolean speed (about 23 s in a debug build, unmeasured in release), history-carrying solids, and AutoCAD acceptance, which cannot be tested here. | +| Region | **Mapped read-only; transform untested** | Mapped like Solid3D and accepted by `doc.solids.transform`, which lifts the payload with `kernel_acis_body`, but no Region fixture exercises it yet. Creation from curve handles (the REGION command's `add_region_model`) is the next operation to add. | +| Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. | +| Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: booleans (about 23 s each in a debug build; release timing needed first), solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. | | Surface | **Blocked, unblocked by design** | Not mapped yet, and the most involved: a surface carries `surface_data` beside `acis_data`, and only a plane keeps its kind when regenerated. Plan: planes first, then extruded and revolved from a profile. See [cadkernel-body-path.md](cadkernel-body-path.md). | | Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 375e3bb3c..1b6d4598f 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 38 geometry converters. Other writable properties +canvas kinds outside its 41 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -92,7 +92,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 38-kind generated schema it contains only handle, kind, and layer; +outside the 41-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index bfe545089..217e08f97 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 38 mapped kinds. +the 41 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 5bca21be9..7accbdf77 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -37,7 +37,10 @@ "Viewport", "ViewBorder", "Light", - "Ole2Frame" + "Ole2Frame", + "Solid3D", + "Body", + "Region" ], "manual_kinds": [ "Dimension" @@ -807,6 +810,87 @@ "read_only": true } } + }, + "Solid3D": { + "update_only": true, + "update_only_message": "Solid3D is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload", + "fields": { + "acis_data": { + "exclude": true, + "_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script." + }, + "wires": { + "exclude": true, + "_comment": "Derived edge wires; regenerated by the host." + }, + "silhouettes": { + "exclude": true, + "_comment": "Derived; regenerated by the host." + }, + "uid": { + "read_only": true + }, + "point_of_reference": { + "read_only": true + }, + "history_handle": { + "read_only": true + } + } + }, + "Body": { + "update_only": true, + "update_only_message": "Body is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload", + "fields": { + "acis_data": { + "exclude": true, + "_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script." + }, + "wires": { + "exclude": true, + "_comment": "Derived edge wires; regenerated by the host." + }, + "silhouettes": { + "exclude": true, + "_comment": "Derived; regenerated by the host." + }, + "uid": { + "read_only": true + }, + "point_of_reference": { + "read_only": true + }, + "history_handle": { + "read_only": true + } + } + }, + "Region": { + "update_only": true, + "update_only_message": "Region is built and edited by the geometry kernel: use doc.solids to create solids and move them; a script never writes the ACIS payload", + "fields": { + "acis_data": { + "exclude": true, + "_comment": "Opaque ACIS payload; snapshot-readable and never rewritten by a script." + }, + "wires": { + "exclude": true, + "_comment": "Derived edge wires; regenerated by the host." + }, + "silhouettes": { + "exclude": true, + "_comment": "Derived; regenerated by the host." + }, + "uid": { + "read_only": true + }, + "point_of_reference": { + "read_only": true + }, + "history_handle": { + "read_only": true + } + } } } } diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 1e5d4281d..440af6981 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -84,10 +84,65 @@ class _Transaction: return False +class _Solids: + """Kernel-backed solid creation and rigid moves. + + The host builds and edits the geometry; a script never sees the ACIS + payload. Every call is its own operation in the script's undo group and + fails, leaving the drawing unchanged, when the kernel cannot do it. + """ + + def __init__(self, document): + self._document = document + + def _create(self, primitive, values, layer): + handle = ocs.solid_create(primitive, [float(v) for v in values], layer) + return self._document.entities[handle] + + def box(self, center=(0, 0, 0), size=(1, 1, 1), layer=None): + return self._create("box", tuple(center) + tuple(size), layer) + + def wedge(self, origin=(0, 0, 0), size=(1, 1, 1), layer=None): + return self._create("wedge", tuple(origin) + tuple(size), layer) + + def cylinder(self, center=(0, 0, 0), radius=1, height=1, layer=None): + return self._create("cylinder", tuple(center) + (radius, height), layer) + + def sphere(self, center=(0, 0, 0), radius=1, layer=None): + return self._create("sphere", tuple(center) + (radius,), layer) + + def torus(self, center=(0, 0, 0), major=2, minor=0.5, layer=None): + return self._create("torus", tuple(center) + (major, minor), layer) + + def pyramid(self, center=(0, 0, 0), radius=1, height=1, sides=4, layer=None): + return self._create("pyramid", tuple(center) + (radius, height, sides), layer) + + def transform(self, entity, matrix): + """Apply a column-major 4x4 rigid transform (16 numbers) in place.""" + handle = entity.handle if isinstance(entity, _Entity) else int(entity) + ocs.solid_transform(handle, [float(v) for v in matrix]) + return self._document.entities[handle] + + def translate(self, entity, offset): + dx, dy, dz = (float(v) for v in offset) + return self.transform(entity, [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, dx, dy, dz, 1]) + + def rotate(self, entity, axis, angle, about=(0, 0, 0)): + """Turn about the world axis 'x', 'y' or 'z' through `about`.""" + index = {"x": 0, "y": 1, "z": 2}[axis] + return self.transform(entity, ocs.rotation_matrix(index, float(angle), [float(v) for v in about])) + + def mirror(self, entity, axis, about=(0, 0, 0)): + """Reflect across the plane normal to the world axis through `about`.""" + index = {"x": 0, "y": 1, "z": 2}[axis] + return self.transform(entity, ocs.mirror_matrix(index, [float(v) for v in about])) + + class _Document: def __init__(self): self._pending = None self.entities = _Entities(self) + self.solids = _Solids(self) def transaction(self, label): return _Transaction(self, label) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 1d348c8b9..130f3da17 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -411,6 +411,114 @@ mod ocs { .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned())) } + /// Build a kernel solid. `values` are the primitive's numbers in a fixed + /// order (see `document_model.py`); the host validates and builds it. + #[pyfunction] + fn solid_create( + primitive: String, + values: Vec, + layer: Option, + vm: &VirtualMachine, + ) -> PyResult { + use ocs_plugin_api::host::{SolidOperation, SolidPrimitive}; + let expect = |count: usize| -> PyResult<()> { + if values.len() == count { + Ok(()) + } else { + Err(vm.new_value_error(format!( + "ocs.solid_create: {primitive} takes {count} numbers, got {}", + values.len() + ))) + } + }; + let v3 = |i: usize| [values[i], values[i + 1], values[i + 2]]; + let primitive = match primitive.as_str() { + "box" => { expect(6)?; SolidPrimitive::Box { center: v3(0), size: v3(3) } } + "wedge" => { expect(6)?; SolidPrimitive::Wedge { origin: v3(0), size: v3(3) } } + "cylinder" => { expect(5)?; SolidPrimitive::Cylinder { center: v3(0), radius: values[3], height: values[4] } } + "sphere" => { expect(4)?; SolidPrimitive::Sphere { center: v3(0), radius: values[3] } } + "torus" => { expect(5)?; SolidPrimitive::Torus { center: v3(0), major: values[3], minor: values[4] } } + "pyramid" => { + expect(6)?; + if values[5] < 0.0 || values[5].fract() != 0.0 || values[5] > f64::from(u32::MAX) { + return Err(vm.new_value_error("ocs.solid_create: sides must be a whole number".to_owned())); + } + SolidPrimitive::Pyramid { center: v3(0), radius: values[3], height: values[4], sides: values[5] as u32 } + } + other => return Err(vm.new_value_error(format!("ocs.solid_create: unknown primitive {other:?}"))), + }; + // The host records its own undo step once the operation validates, like + // an entity transaction, so a refused operation leaves no empty step. + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::Create { primitive, layer }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_create: {error}"))) + } + + /// Apply a column-major 4x4 rigid transform to a solid, body or region. + #[pyfunction] + fn solid_transform(handle: u64, matrix: Vec, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let matrix: [f64; 16] = matrix.try_into().map_err(|_| { + vm.new_value_error("ocs.solid_transform: the matrix needs 16 numbers".to_owned()) + })?; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::Transform { handle: Handle::new(handle), matrix }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_transform: {error}"))) + } + + /// Column-major matrix that turns about a world axis (0=X, 1=Y, 2=Z) + /// through the point `about`. The sandbox has no `math` module. + #[pyfunction] + fn rotation_matrix(axis: usize, angle: f64, about: Vec, vm: &VirtualMachine) -> PyResult { + if axis > 2 || about.len() != 3 || !angle.is_finite() || about.iter().any(|v| !v.is_finite()) { + return Err(vm.new_value_error("ocs.rotation_matrix: axis 0-2, a finite angle and a 3-point".to_owned())); + } + let (sin, cos) = angle.sin_cos(); + let (x, y, z) = match axis { + 0 => ([1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]), + 1 => ([cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]), + _ => ([cos, sin, 0.0], [-sin, cos, 0.0], [0.0, 0.0, 1.0]), + }; + Ok(matrix_to_py(vm, frame_matrix(x, y, z, &about))) + } + + /// Column-major matrix that reflects across the plane normal to a world + /// axis through the point `about`. + #[pyfunction] + fn mirror_matrix(axis: usize, about: Vec, vm: &VirtualMachine) -> PyResult { + if axis > 2 || about.len() != 3 || about.iter().any(|v| !v.is_finite()) { + return Err(vm.new_value_error("ocs.mirror_matrix: axis 0-2 and a finite 3-point".to_owned())); + } + let mut columns = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + columns[axis][axis] = -1.0; + let [x, y, z] = columns; + Ok(matrix_to_py(vm, frame_matrix(x, y, z, &about))) + } + + fn matrix_to_py(vm: &VirtualMachine, matrix: Vec) -> PyObjectRef { + vm.ctx.new_list(matrix.into_iter().map(|value| vm.new_pyobj(value)).collect()).into() + } + + /// A transform about `about` has origin `about - M * about`. + fn frame_matrix(x: [f64; 3], y: [f64; 3], z: [f64; 3], about: &[f64]) -> Vec { + let mut origin = [about[0], about[1], about[2]]; + for axis in 0..3 { + origin[axis] -= x[axis] * about[0] + y[axis] * about[1] + z[axis] * about[2]; + } + vec![ + x[0], x[1], x[2], 0.0, y[0], y[1], y[2], 0.0, z[0], z[1], z[2], 0.0, + origin[0], origin[1], origin[2], 1.0, + ] + } + /// Delete one entity in the script's shared undo group. #[pyfunction] fn remove_entity(handle: u64, vm: &VirtualMachine) -> PyResult<()> { @@ -4780,7 +4888,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 38); + assert_eq!(emitted.len(), 41); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 0fb2bae30..950b8e2ff 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -530,6 +530,151 @@ impl<'a> HostSession<'a> { } } + /// Kernel-backed solid create or transform. Every result is verified by + /// `solid_to_sat` (the exported payload must lift back with no loss) and an + /// existing payload is only transformed when it lifts losslessly, so a + /// failure never changes the document. + pub fn solid_operation( + &mut self, + operation: ocs_plugin_api::host::SolidOperation, + ) -> Result { + use crate::scene::model::solid_model as model; + use ocs_plugin_api::host::{SolidOperation, SolidPrimitive}; + let positive = |name: &str, value: f64| { + if value.is_finite() && value > 0.0 { + Ok(()) + } else { + Err(format!("{name} must be finite and greater than zero")) + } + }; + let finite3 = |name: &str, value: [f64; 3]| { + if value.iter().all(|v| v.is_finite()) { + Ok(()) + } else { + Err(format!("{name} must be finite")) + } + }; + match operation { + SolidOperation::Create { primitive, layer } => { + let body = match primitive { + SolidPrimitive::Box { center, size } => { + finite3("box center", center)?; + for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] { + positive(&format!("box {name}"), v)?; + } + model::box_solid(center, size[0], size[1], size[2]) + } + SolidPrimitive::Wedge { origin, size } => { + finite3("wedge origin", origin)?; + for (name, v) in [("length", size[0]), ("width", size[1]), ("height", size[2])] { + positive(&format!("wedge {name}"), v)?; + } + model::wedge_solid(origin, size[0], size[1], size[2]) + } + SolidPrimitive::Cylinder { center, radius, height } => { + finite3("cylinder center", center)?; + positive("cylinder radius", radius)?; + positive("cylinder height", height)?; + model::cylinder_solid(center, radius, height) + } + SolidPrimitive::Sphere { center, radius } => { + finite3("sphere center", center)?; + positive("sphere radius", radius)?; + model::sphere_solid(center, radius) + } + SolidPrimitive::Torus { center, major, minor } => { + finite3("torus center", center)?; + positive("torus major radius", major)?; + positive("torus minor radius", minor)?; + if minor >= major { + return Err("torus minor radius must be smaller than the major radius".into()); + } + model::torus_solid(center, major, minor) + } + SolidPrimitive::Pyramid { center, radius, height, sides } => { + finite3("pyramid center", center)?; + positive("pyramid radius", radius)?; + positive("pyramid height", height)?; + if !(3..=1024).contains(&sides) { + return Err("pyramid sides must be between 3 and 1024".into()); + } + model::pyramid_solid(center, radius, height, sides as usize) + } + } + .ok_or("the geometry kernel refused these dimensions")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("the solid could not be exported losslessly")?; + let mut solid = acadrust::entities::Solid3D::new(); + solid.wires = model::edge_wires(&body); + solid.set_sat_document(&sat); + if let Some(layer) = layer { + if layer.trim().is_empty() { + return Err("layer name is empty".into()); + } + solid.common.layer = layer; + } + self.push_undo("Create solid"); + let handle = self.add_entity(EntityType::Solid3D(solid)); + if handle.is_null() { + return Err("the solid could not be added".into()); + } + Ok(handle) + } + SolidOperation::Transform { handle, matrix } => { + if matrix.iter().any(|v| !v.is_finite()) { + return Err("transform matrix must be finite".into()); + } + let column = |i: usize| [matrix[i], matrix[i + 1], matrix[i + 2]]; + let (x, y, z) = (column(0), column(4), column(8)); + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + let rigid = [x, y, z].iter().all(|c| (dot(*c, *c) - 1.0).abs() < 1e-6) + && dot(x, y).abs() < 1e-6 + && dot(x, z).abs() < 1e-6 + && dot(y, z).abs() < 1e-6 + && matrix[3].abs() < 1e-9 + && matrix[7].abs() < 1e-9 + && matrix[11].abs() < 1e-9 + && (matrix[15] - 1.0).abs() < 1e-9; + if !rigid { + return Err("only rigid moves and mirrors are supported; scaling and shear are not".into()); + } + let mut entity = self + .document() + .get_entity(handle) + .cloned() + .ok_or_else(|| format!("entity {handle:?} does not exist"))?; + if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + return Err(format!("entity {handle:?} is on a locked layer")); + } + macro_rules! retarget { + ($value:expr) => {{ + let body = crate::scene::convert::solid3d_tess::kernel_acis_body(&$value.acis_data) + .ok_or("this body's payload cannot be lifted losslessly, so it is left untouched")?; + let moved = model::by_matrix(&body, matrix) + .ok_or("the geometry kernel refused this transform")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&moved) + .ok_or("the moved body could not be exported losslessly")?; + $value.wires = model::edge_wires(&moved); + $value.silhouettes.clear(); + $value.history_handle = None; + $value.set_sat_document(&sat); + }}; + } + match &mut entity { + EntityType::Solid3D(value) => retarget!(value), + EntityType::Body(value) => retarget!(value), + EntityType::Region(value) => retarget!(value), + _ => return Err("transform applies to Solid3D, Body and Region entities".into()), + } + self.push_undo("Transform solid"); + if !self.update_entity(entity) { + return Err(format!("entity {handle:?} could not be updated")); + } + Ok(handle) + } + } + } + pub fn push_undo(&mut self, label: &str) { self.app.push_undo_snapshot(self.tab, label); } @@ -622,6 +767,12 @@ impl HostApi for HostSession<'_> { fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> { self.set_selection(handles) } + fn solid_operation( + &mut self, + operation: ocs_plugin_api::host::SolidOperation, + ) -> Result { + self.solid_operation(operation) + } fn remove_entity(&mut self, handle: Handle) -> bool { self.remove_entity(handle) } @@ -4558,6 +4709,199 @@ mod tests { time("volume(union)", &mut || format!("{:.1}", sm::volume(&result))); } + #[test] + fn audit_python_solid3d_lifecycle_over_real_ipc() { + use crate::scene::convert::solid3d_tess::kernel_body; + use crate::scene::model::solid_model as sm; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let solids = |host: &HostSession<'_>| -> Vec { + let mut list: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect(); + list.sort_by_key(|h| h.value()); + list + }; + let body_of = |host: &HostSession<'_>, handle: Handle| { + let Some(EntityType::Solid3D(solid)) = host.document().get_entity(handle) else { panic!("no solid {handle:?}") }; + kernel_body(solid).expect("payload lifts losslessly") + }; + let volume = |host: &HostSession<'_>, handle: Handle| sm::volume(&body_of(host, handle)); + let extent = |host: &HostSession<'_>, handle: Handle| sm::extent(&body_of(host, handle)).unwrap(); + let close = |a: f64, b: f64, tolerance: f64| (a - b).abs() <= tolerance * b.abs().max(1.0); + + // C: every primitive, checked against its analytic volume. + let script = std::env::temp_dir().join(format!("ocs_solid_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "s = ocs.active_document.solids\n", + "s.box(center=(0, 0, 0), size=(10, 6, 4))\n", + "s.cylinder(center=(20, 0, 0), radius=2, height=5)\n", + "s.sphere(center=(40, 0, 0), radius=3)\n", + "s.torus(center=(60, 0, 0), major=5, minor=1)\n", + "s.wedge(origin=(80, 0, 0), size=(4, 3, 2))\n", + "s.pyramid(center=(100, 0, 0), radius=3, height=4, sides=5)\n", + "s.box(center=(0, 20, 0), size=(2, 2, 2), layer='SOLIDS')\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let list = solids(&host); + assert_eq!(list.len(), 7, "{}", last(&host)); + let pi = std::f64::consts::PI; + let five_gon = 0.5 * 5.0 * 3.0f64.powi(2) * (2.0 * pi / 5.0).sin(); + for (index, expected, tolerance) in [ + (0, 240.0, 1e-9), (1, pi * 4.0 * 5.0, 0.02), (2, 4.0 / 3.0 * pi * 27.0, 0.02), + (3, 2.0 * pi * pi * 5.0 * 1.0, 0.02), (4, 12.0, 1e-9), (5, five_gon * 4.0 / 3.0, 0.02), (6, 8.0, 1e-9), + ] { + let actual = volume(&host, list[index]); + assert!(close(actual, expected, tolerance), "solid {index}: volume {actual}, expected {expected}"); + } + let Some(EntityType::Solid3D(first)) = host.document().get_entity(list[0]) else { unreachable!() }; + assert_eq!((first.wires.len(), first.common.layer.as_str()), (12, "0")); + assert!(matches!(host.document().get_entity(list[6]), Some(EntityType::Solid3D(s)) if s.common.layer == "SOLIDS")); + assert!(!host.app.tabs[0].scene.wire_models_for(&[list[0]]).is_empty(), "no canvas geometry"); + let read = |host: &mut HostSession<'_>, expression: &str| { + dispatch(host, &format!("PY_EVAL {expression}")); + last(host) + }; + assert!(read(&mut host, &format!("ocs.active_document.entities[{}].kind", list[0].value())).contains("Solid3D")); + + // E: rigid moves keep the volume and move the extent exactly. + let box_handle = list[0]; + let before = host.document().get_entity(box_handle).unwrap().clone(); + let undo_before = host.app.tabs[0].history.undo_stack.len(); + let (low, high) = extent(&host, box_handle); + assert!(close(low[0], -5.0, 1e-6) && close(high[2], 2.0, 1e-6), "{low:?} {high:?}"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (5, 5, 5)).handle", box_handle.value())); + let (low, high) = extent(&host, box_handle); + assert!(close(low[0], 0.0, 1e-6) && close(high[1], 8.0, 1e-6) && close(high[2], 7.0, 1e-6), "{low:?} {high:?}"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.rotate({}, 'z', 0.7853981633974483, (5, 5, 5)).handle", box_handle.value())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.mirror({}, 'x', (0, 0, 0)).handle", box_handle.value())); + assert!(close(volume(&host, box_handle), 240.0, 1e-9), "moves preserve volume"); + let Some(EntityType::Solid3D(moved)) = host.document().get_entity(box_handle) else { unreachable!() }; + assert_eq!((moved.wires.len(), moved.history_handle, moved.common.handle), (12, None, box_handle)); + assert_eq!(moved.common.layer, "0"); + assert_eq!(host.app.tabs[0].history.undo_stack.len() - undo_before, 3, "one undo step per move"); + + // V: refused operations change nothing and leave no undo step. + let snapshot = host.document().get_entity(box_handle).unwrap().clone(); + let count_before = solids(&host).len(); + let undo_now = host.app.tabs[0].history.undo_stack.len(); + let empty_payload = acadrust::entities::Solid3D::new(); + let unliftable = host.add_entity(EntityType::Solid3D(empty_payload.clone())); + let unliftable_before = host.document().get_entity(unliftable).unwrap().clone(); + for (command, message) in [ + ("ocs.active_document.solids.box(size=(0, 1, 1))".to_string(), "greater than zero"), + ("ocs.active_document.solids.box(center=(float('nan'), 0, 0))".to_string(), "must be finite"), + ("ocs.active_document.solids.torus(major=1, minor=2)".to_string(), "smaller than the major"), + ("ocs.active_document.solids.pyramid(sides=2)".to_string(), "between 3 and 1024"), + ("ocs.active_document.solids.sphere(radius=-1)".to_string(), "greater than zero"), + ("ocs.active_document.solids.box(layer='')".to_string(), "layer name is empty"), + ("ocs.solid_create('blob', [1.0], None)".to_string(), "unknown primitive"), + ("ocs.solid_create('box', [1.0], None)".to_string(), "takes 6 numbers"), + (format!("ocs.active_document.solids.transform({}, [2,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1])", box_handle.value()), "rigid moves"), + (format!("ocs.active_document.solids.transform({}, [1]*16)", box_handle.value()), "rigid moves"), + (format!("ocs.active_document.solids.translate({}, (float('nan'), 0, 0))", box_handle.value()), "must be finite"), + (format!("ocs.active_document.solids.translate({}, (1, 0, 0))", unliftable.value()), "cannot be lifted losslessly"), + ("ocs.active_document.solids.translate(999999, (1, 0, 0))".to_string(), "does not exist"), + ] { + dispatch(&mut host, &format!("PY_EVAL {command}")); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!(host.document().get_entity(box_handle), Some(&snapshot)); + assert_eq!(host.document().get_entity(unliftable), Some(&unliftable_before)); + assert_eq!(solids(&host).len(), count_before + 1, "only the fixture was added"); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo_now, "refusals record no undo step"); + // Transforming a non-solid is refused. + let line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(1.0, 0.0, 0.0)))); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (1, 0, 0))", line.value())); + assert!(last(&host).contains("outside the Python schema") || last(&host).contains("applies to Solid3D"), "{}", last(&host)); + + // W: both formats reopen the moved solid losslessly; the reopened + // payload can be moved again. + let moved_volume = volume(&host, box_handle); + let moved_extent = extent(&host, box_handle); + let dwg = crate::io::load_bytes("solid.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("solid.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + let Some(EntityType::Solid3D(back)) = document.get_entity(box_handle) else { panic!("{format} lost the solid") }; + let body = kernel_body(back).unwrap_or_else(|| panic!("{format} payload does not lift")); + assert!(close(sm::volume(&body), moved_volume, 1e-6), "{format}"); + let reopened_extent = sm::extent(&body).unwrap(); + for axis in 0..3 { + assert!(close(reopened_extent.0[axis], moved_extent.0[axis], 1e-6) && close(reopened_extent.1[axis], moved_extent.1[axis], 1e-6), "{format} extent"); + } + let again = host.add_entity(EntityType::Solid3D(back.clone())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value())); + let (low, _) = extent(&host, again); + assert!(close(low[0], reopened_extent.0[0] + 100.0, 1e-6), "{format} re-edit"); + } + + // D and U: delete, then undo and redo restore the moved state. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", box_handle.value())); + assert!(host.document().get_entity(box_handle).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + // Steps after creation: 3 moves, 2 re-edits of reopened copies, 1 delete. + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(box_handle).is_some(), "undo restores the deleted solid"); + app.undo_steps(5); + assert_eq!(app.tabs[0].scene.document.get_entity(box_handle), Some(&before), "undoing every move restores the original solid exactly"); + app.redo_steps(6); + assert!(app.tabs[0].scene.document.get_entity(box_handle).is_none(), "redo replays the moves and the delete"); + } + + #[test] + fn audit_python_body_transform_over_real_ipc() { + use crate::scene::model::solid_model as sm; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + // A Body entity carrying a kernel box; scripts cannot create one. + let boxed = sm::box_solid([0.0; 3], 4.0, 4.0, 4.0).unwrap(); + let mut body = acadrust::entities::Body::new(); + body.set_sat_document(&crate::scene::convert::acis_export::solid_to_sat(&boxed).unwrap()); + body.wires = sm::edge_wires(&boxed); + let handle = host.add_entity(EntityType::Body(body)); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let extent = |host: &HostSession<'_>| { + let Some(EntityType::Body(b)) = host.document().get_entity(handle) else { panic!("no body") }; + sm::extent(&crate::scene::convert::solid3d_tess::kernel_acis_body(&b.acis_data).unwrap()).unwrap() + }; + assert!((extent(&host).0[0] + 2.0).abs() < 1e-6); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", handle.value())); + assert!(host.app.command_line.history.last().unwrap().text.contains("Body")); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 0, 0)).handle", handle.value())); + assert!((extent(&host).0[0] - 8.0).abs() < 1e-6, "{:?}", extent(&host)); + let bytes = acadrust::DwgWriter::write_to_vec(host.document()).unwrap(); + let reopened = crate::io::load_bytes("body.dwg", bytes).unwrap(); + let Some(EntityType::Body(back)) = reopened.get_entity(handle) else { panic!("DWG lost the body") }; + let lifted = crate::scene::convert::solid3d_tess::kernel_acis_body(&back.acis_data).expect("DWG body lifts"); + assert!((sm::volume(&lifted) - 64.0).abs() < 1e-6); + // Scripts cannot create a Body and get the explanatory message. + dispatch(&mut host, "PY_EVAL ocs.active_document.create_entity('Body')"); + assert!(host.app.command_line.history.last().unwrap().text.contains("use doc.solids"), "{}", host.app.command_line.history.last().unwrap().text); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From c731d3a9a4719c8f45e359a371b8d8bbf9386d9c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:37:10 +0200 Subject: [PATCH 41/76] Complete Region: create from a profile, plus tuple and int friendliness RegionFromProfile reuses the REGION command's kernel calls; doc.solids.region exposes it with layer and delete_source options. The lifecycle test builds regions from real circle and polyline entities and covers refusals, a move, both formats and undo. create_entity now accepts coordinate tuples for point properties and every generated float setter accepts Python ints. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 5 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 1 + docs/cadkernel-body-path.md | 2 +- docs/plugin-host-model-completion-plan.md | 7 +- docs/plugin-host-model-coverage-ledger.md | 4 +- plugins/opencad-python/build/generate.rs | 4 + plugins/opencad-python/src/document_model.py | 31 +++- plugins/opencad-python/src/ocs_module.rs | 17 ++ src/app/plugin_host.rs | 169 +++++++++++++++++++ 9 files changed, 233 insertions(+), 7 deletions(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index dcd0402ce..ec59c428e 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -85,6 +85,11 @@ pub enum SolidOperation { /// 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, delete_source: bool }, } /// Value of a host-managed drafting or document setting exposed to plugins. diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index d9c3e8bae..740aa9773 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -114,6 +114,7 @@ mod tests { 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 }, ] { let bytes = bincode::serialize(&PluginRequest::SolidOperation { operation: operation.clone() }).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md index ee70be7c1..ba6ad1e74 100644 --- a/docs/cadkernel-body-path.md +++ b/docs/cadkernel-body-path.md @@ -136,4 +136,4 @@ is refused with a clear message instead of being rewritten. Phase 1 alone would move Solid3D from blocked to complete-in-OCS. -**Status (2026-09-20): phase 1 is done.** `HostApi::solid_operation` (create primitive and rigid transform), the `doc.solids` Python API and `audit_python_solid3d_lifecycle_over_real_ipc` are in; Solid3D is complete, Body moves through the same channel. Design B was implemented as recommended. Booleans, Region creation and Surface remain. +**Status (2026-09-20): phase 1 is done.** `HostApi::solid_operation` (create primitive and rigid transform), the `doc.solids` Python API and `audit_python_solid3d_lifecycle_over_real_ipc` are in; Solid3D is complete, Body moves through the same channel. Design B was implemented as recommended. Region creation from a closed profile followed (`RegionFromProfile`). Booleans and Surface remain. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 8adc7eab1..c3b7e0567 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,9 +7,9 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **41 of 43** canvas kinds: 37 -are creatable (the legacy `Polyline`, `Ole2Frame`, `Body` and `Region` are update-only). This is **not** 41 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D and Solid3D pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. +v7. The Python feature has explicit writes for **41 of 43** canvas kinds: 38 +are creatable (the legacy `Polyline`, `Ole2Frame` and `Body` are update-only). This is **not** 41 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D and Region pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current @@ -178,3 +178,4 @@ exact queue above and update statuses with evidence after each increment. - Mapped `Ole2Frame` as update-only (queue item 21, previously blocked). OCS already builds real embedded frames (`build_embedded_ole`), which supplied the fixture the blocker was waiting for. Frame corners and the aspect lock are scriptable; the opaque storage and envelope are excluded and refused on edit. The audit proves the embedded picture survives DWG and DXF unchanged. Creation from Python remains a follow-up. The remaining blocked kinds are SectionSymbol, Region, Body, Solid3D and Surface. - Investigated the cadkernel path for the ACIS kinds ([cadkernel-body-path.md](cadkernel-body-path.md)). The earlier "blocked" reasoning was overstated: OCS already builds primitives, transforms bodies and runs booleans through cadkernel, and `solid_to_sat` plus `kernel_acis_body` verify each result lifts back with no loss. Two `#[ignore]` spike tests measured exact volumes and DWG and DXF round trips for a box, moved, rotated and mirrored copies, three booleans, four other primitives and a `Body` entity. The real gap is an operation channel from a script to that pipeline; the note recommends an additive `solid_operation` host method and a phased plan (Solid3D create and transform, then booleans and Body, then Region, then Surface). Booleans took about 23 s each in a debug build and need release timing first. Region, Body, Solid3D and Surface move from blocked to unblocked-by-design; SectionSymbol stays blocked. - Built the cadkernel operation channel and completed `Solid3D` (create and rigid move). `HostApi::solid_operation` is additive (default "unsupported"), so older hosts and plugins are unaffected; it travels as `PluginRequest::SolidOperation` and `PluginResponse::SolidResult`, and the host records its own undo step only after validation so a refused operation leaves nothing behind. The Python surface is `doc.solids.*`. Every result is verified lossless by the kernel path before it is accepted and a payload that will not lift is refused untouched. `Body` moves through the same channel and `Region` is accepted but untested. Booleans, Region creation from curves and Surface remain, in that order; booleans wait on release-build timing. Solid3D, Body and Region are now mapped read-only with the opaque payload excluded and preserved. +- Completed `Region`. `SolidOperation::RegionFromProfile` reuses the REGION command's kernel calls and `doc.solids.region` exposes it; the lifecycle test creates regions from real circle and polyline entities and covers deletion of the source, refusals, a move, both formats and undo. Along the way `create_entity` now accepts `(x, y, z)` tuples for point properties (decided from the schema's property types) and every generated float setter accepts Python ints, so `create_entity('Circle', center=(0, 0, 0), radius=5)` works. Left: booleans (release timing first), holes and open-curve merging for regions, and `Surface`. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 59d8363b5..a35d9b2e6 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **41 of 43 canvas kinds have explicit geometry writes** (37 creatable, plus update-only legacy `Polyline`, `Ole2Frame`, `Body` and `Region`), **27 kinds are complete**, and 2 remain layer-only (SectionSymbol blocked, Surface unblocked by design). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **41 of 43 canvas kinds have explicit geometry writes** (38 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **28 kinds are complete**, and 2 remain layer-only (SectionSymbol blocked, Surface unblocked by design). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -44,7 +44,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | | SectionSymbol | **Blocked** | Not mapped. A valid section mark needs a `SectionViewStyle` and a `ViewRep` view-representation object that a script cannot create through the host API, and the entity stores raw point counts and flag words (`raw_point_count_90`, `raw_flags_90`, `raw_point_record_count`, `raw_view_symbol_70`) whose relationship to `points` is undocumented in acadrust and is written back verbatim by both writers, so the host cannot keep them consistent after a scripted edit without guessing. Adding the kind needs (1) host-side creation or lookup of a section style and view representation, and (2) a confirmed derivation of the raw counts from a real DWG produced by another CAD application. The kind keeps raw snapshot access and layer edits. | | Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. | -| Region | **Mapped read-only; transform untested** | Mapped like Solid3D and accepted by `doc.solids.transform`, which lifts the payload with `kernel_acis_body`, but no Region fixture exercises it yet. Creation from curve handles (the REGION command's `add_region_model`) is the next operation to add. | +| Region | **Complete (create from a profile + rigid move)** | New `SolidOperation::RegionFromProfile` and `doc.solids.region(profile, layer=None, delete_source=False)`. The host builds the region exactly as the REGION command does (`presspull_model::profile_geometry` then `cadkernel::brep::planar_region`), verifies it with `solid_to_sat`, puts it on `layer` or on the profile's layer, and erases the profile only when `delete_source` is set. One closed planar profile per call (a circle, ellipse, closed polyline, spline and so on); an open profile, a ray, a region, a missing entity or an empty layer is refused with a message before anything changes. Rigid moves go through the same `doc.solids.transform` channel as Solid3D. `audit_python_region_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: regions from a circle (source kept), a closed rectangle (source deleted) and a second circle on its own layer with exact extents, canvas geometry, six refusals that change nothing and record no undo step, a move with the exact shifted extent, DWG and DXF reopen with the payload lifting and the same extent, a reopened region moving again, and undo restoring the region exactly. Not covered: regions with holes and merging open curves into a boundary (the REGION command's second path), and AutoCAD acceptance of the payload. | | Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. | | Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: booleans (about 23 s each in a debug build; release timing needed first), solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. | | Surface | **Blocked, unblocked by design** | Not mapped yet, and the most involved: a surface carries `surface_data` beside `acis_data`, and only a plane keeps its kind when regenerated. Plan: planes first, then extruded and revolved from a profile. See [cadkernel-body-path.md](cadkernel-body-path.md). | diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index c07b9c39d..b8b511810 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -406,6 +406,10 @@ fn leaf_to_py(registry: &TypeRegistry, type_id: &str, item_expr: &str) -> String /// `value_expr` must already be an owned `PyObjectRef` expression. Result: a `PyResult` expression. fn leaf_from_py(registry: &TypeRegistry, type_id: &str, value_expr: &str) -> String { + if type_id == "f64" { + // RustPython's f64 conversion rejects ints; scripts write `radius=5`. + return format!("py_number_to_f64({value_expr}, vm)"); + } if type_id == "u64" { // serde reports Rust `usize` as `u64`; the inferred cast serves both. return format!("{value_expr}.try_into_value::(vm).map(|n| n as _)"); diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 440af6981..7c964ef80 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -1,5 +1,27 @@ """Thin Python view over OCS host API v7 entity transactions and coverage.""" +def _coordinates(value, width): + return dict(zip("xyz"[:width], (float(axis) for axis in value))) + + +def _coerce_points(kind, properties): + """Let create_entity take (x, y, z) tuples for point properties, as + transaction edits already do, by using the property types in the schema.""" + schema = ocs.entity_coverage(kind) + types = {row["name"]: row for row in schema["properties"]} if schema else {} + result = {} + for name, value in properties.items(): + row = types.get(name) + if row is not None and row["type"] in ("Vector2", "Vector3"): + width = 2 if row["type"] == "Vector2" else 3 + if row["sequence"]: + value = [_coordinates(item, width) if isinstance(item, (tuple, list)) else item for item in value] + elif isinstance(value, (tuple, list)): + value = _coordinates(value, width) + result[name] = value + return result + + class _Entity: def __init__(self, document, handle): object.__setattr__(self, "_document", document) @@ -117,6 +139,13 @@ class _Solids: def pyramid(self, center=(0, 0, 0), radius=1, height=1, sides=4, layer=None): return self._create("pyramid", tuple(center) + (radius, height, sides), layer) + def region(self, profile, layer=None, delete_source=False): + """Make a planar region from one closed planar profile (a circle, + ellipse, closed polyline or spline). Keeps the profile unless + `delete_source` is true, and uses the profile's layer by default.""" + handle = profile.handle if isinstance(profile, _Entity) else int(profile) + return self._document.entities[ocs.solid_region(handle, layer, bool(delete_source))] + def transform(self, entity, matrix): """Apply a column-major 4x4 rigid transform (16 numbers) in place.""" handle = entity.handle if isinstance(entity, _Entity) else int(entity) @@ -151,7 +180,7 @@ class _Document: """Create one mapped entity and return its live document descriptor.""" if "kind" in properties or "handle" in properties: raise ValueError("kind and handle are managed by create_entity") - handle = ocs.add(dict(kind=kind, **properties)) + handle = ocs.add(dict(kind=kind, **_coerce_points(kind, properties))) return self.entities[handle] def delete_entity(self, entity): diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 130f3da17..dcfdb140b 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -458,6 +458,23 @@ mod ocs { .map_err(|error| vm.new_runtime_error(format!("ocs.solid_create: {error}"))) } + /// Build a planar region from a closed planar profile entity. + #[pyfunction] + fn solid_region(source: u64, layer: Option, delete_source: bool, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::RegionFromProfile { + source: Handle::new(source), + layer, + delete_source, + }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_region: {error}"))) + } + /// Apply a column-major 4x4 rigid transform to a solid, body or region. #[pyfunction] fn solid_transform(handle: u64, matrix: Vec, vm: &VirtualMachine) -> PyResult { diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 950b8e2ff..9db23fa3c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -620,6 +620,47 @@ impl<'a> HostSession<'a> { } Ok(handle) } + SolidOperation::RegionFromProfile { source, layer, delete_source } => { + if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { + return Err("layer name is empty".into()); + } + let entity = self + .document() + .get_entity(source) + .cloned() + .ok_or_else(|| format!("entity {source:?} does not exist"))?; + if matches!(entity, EntityType::Region(_)) { + return Err("the source is already a region".into()); + } + if delete_source && self.app.tabs[self.tab].scene.is_layer_locked(source) { + return Err(format!("entity {source:?} is on a locked layer")); + } + let (plane, loops, closed) = crate::scene::model::presspull_model::profile_geometry(&entity) + .ok_or("the source is not a planar profile a region can be made from")?; + if !closed { + return Err("the profile must be closed".into()); + } + let body = cadkernel::brep::planar_region(plane, &loops) + .ok_or("the geometry kernel could not build a region from this profile")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("the region could not be exported losslessly")?; + let mut region = acadrust::entities::Region::new(); + region.point_of_reference = + acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); + region.wires = model::edge_wires(&body); + region.set_sat_document(&sat); + region.common.layer = layer.unwrap_or_else(|| entity.common().layer.clone()); + self.push_undo("Create region"); + let handle = self.add_entity(EntityType::Region(region)); + if handle.is_null() { + return Err("the region could not be added".into()); + } + if delete_source { + self.app.tabs[self.tab].scene.erase_entities(&[source]); + self.publish_document_view(); + } + Ok(handle) + } SolidOperation::Transform { handle, matrix } => { if matrix.iter().any(|v| !v.is_finite()) { return Err("transform matrix must be finite".into()); @@ -4902,6 +4943,134 @@ mod tests { assert!(host.app.command_line.history.last().unwrap().text.contains("use doc.solids"), "{}", host.app.command_line.history.last().unwrap().text); } + #[test] + fn audit_python_region_lifecycle_over_real_ipc() { + use crate::scene::convert::solid3d_tess::kernel_acis_body; + use crate::scene::model::solid_model as sm; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let regions = |host: &HostSession<'_>| -> Vec { + let mut list: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Region(r) => Some(r.common.handle), _ => None }).collect(); + list.sort_by_key(|h| h.value()); + list + }; + let extent_in = |document: &CadDocument, handle: Handle| { + let Some(EntityType::Region(region)) = document.get_entity(handle) else { panic!("no region {handle:?}") }; + let body = kernel_acis_body(®ion.acis_data).expect("region payload lifts losslessly"); + sm::extent(&body).unwrap() + }; + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + + // C: regions from a circle (source kept), a closed rectangle (source + // deleted) and a second circle on its own layer. + let script = std::env::temp_dir().join(format!("ocs_region_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "doc = ocs.active_document\n", + "circle = doc.create_entity('Circle', center=(0, 0, 0), radius=5)\n", + "rect = doc.create_entity('LwPolyline', is_closed=True, vertices=[{'location': {'x': 20.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 6.0}}, {'location': {'x': 20.0, 'y': 6.0}}])\n", + "doc.solids.region(circle)\n", + "doc.solids.region(rect, delete_source=True)\n", + "other = doc.create_entity('Circle', center=(50, 0, 0), radius=2, layer='PROFILES')\n", + "doc.solids.region(other, layer='REGIONS')\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let list = regions(&host); + assert_eq!(list.len(), 3, "{}", last(&host)); + let (low, high) = extent_in(host.document(), list[0]); + assert!(close(low[0], -5.0) && close(high[0], 5.0) && close(low[1], -5.0) && close(high[1], 5.0) && close(low[2], 0.0) && close(high[2], 0.0), "{low:?} {high:?}"); + let (low, high) = extent_in(host.document(), list[1]); + assert!(close(low[0], 20.0) && close(high[0], 30.0) && close(high[1], 6.0), "{low:?} {high:?}"); + let layer_of = |host: &HostSession<'_>, handle: Handle| host.document().get_entity(handle).unwrap().common().layer.clone(); + assert_eq!(layer_of(&host, list[0]), "0", "defaults to the profile's layer"); + assert_eq!(layer_of(&host, list[2]), "REGIONS"); + let circles = host.document().entities().filter(|e| matches!(e, EntityType::Circle(_))).count(); + let polylines = host.document().entities().filter(|e| matches!(e, EntityType::LwPolyline(_))).count(); + assert_eq!((circles, polylines), (2, 0), "delete_source removed only the rectangle"); + let Some(EntityType::Region(first)) = host.document().get_entity(list[0]) else { unreachable!() }; + assert_eq!((first.wires.is_empty(), first.point_of_reference.z), (false, 0.0)); + assert!(!host.app.tabs[0].scene.wire_models_for(&[list[0]]).is_empty(), "no canvas geometry"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", list[0].value())); + assert!(last(&host).contains("Region")); + + // V: refusals change nothing and leave no undo step. + let open_line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(5.0, 0.0, 0.0)))); + let mut open_poly = acadrust::entities::LwPolyline::new(); + open_poly.vertices = vec![ + acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(0.0, 0.0)), + acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(4.0, 0.0)), + acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(4.0, 4.0)), + ]; + let open_poly = host.add_entity(EntityType::LwPolyline(open_poly)); + let ray = host.add_entity(EntityType::Ray(acadrust::entities::Ray::default())); + let entity_count = host.document().entities().count(); + let undo_now = host.app.tabs[0].history.undo_stack.len(); + for (command, message) in [ + (format!("ocs.active_document.solids.region({})", open_line.value()), "must be closed"), + (format!("ocs.active_document.solids.region({})", open_poly.value()), "must be closed"), + (format!("ocs.active_document.solids.region({})", ray.value()), "planar profile"), + (format!("ocs.active_document.solids.region({})", list[0].value()), "already a region"), + ("ocs.active_document.solids.region(999999)".to_string(), "does not exist"), + (format!("ocs.active_document.solids.region({}, layer='')", open_line.value()), "layer name is empty"), + ] { + dispatch(&mut host, &format!("PY_EVAL {command}")); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!(host.document().entities().count(), entity_count); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo_now, "refusals record no undo step"); + + // E: a region moves through the same kernel channel. + let before = host.document().get_entity(list[0]).unwrap().clone(); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 5, 0)).handle", list[0].value())); + let (low, high) = extent_in(host.document(), list[0]); + assert!(close(low[0], 5.0) && close(high[0], 15.0) && close(low[1], 0.0) && close(high[1], 10.0), "{low:?} {high:?}"); + + // W: both formats reopen a region that lifts with the same extent, and + // a reopened region moves again. + let dwg = crate::io::load_bytes("region.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("region.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + let moved = extent_in(host.document(), list[0]); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + assert_eq!(regions_in(document).len(), 3, "{format}"); + let reopened = extent_in(document, list[0]); + for axis in 0..3 { + assert!(close(reopened.0[axis], moved.0[axis]) && close(reopened.1[axis], moved.1[axis]), "{format} extent {reopened:?} vs {moved:?}"); + } + let Some(EntityType::Region(back)) = document.get_entity(list[0]) else { unreachable!() }; + let again = host.add_entity(EntityType::Region(back.clone())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value())); + assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit"); + } + fn regions_in(document: &CadDocument) -> Vec { + document.entities().filter_map(|e| match e { EntityType::Region(r) => Some(r.common.handle), _ => None }).collect() + } + + // D and U: delete, undo, and undo the creation steps exactly. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", list[2].value())); + assert!(host.document().get_entity(list[2]).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(list[2]).is_some(), "undo restores the deleted region"); + // Two re-edits of reopened copies, then the move of the first region. + app.undo_steps(3); + assert_eq!(app.tabs[0].scene.document.get_entity(list[0]), Some(&before), "undoing the move restores the region exactly"); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 918351f2647a0826c65c5259780f483fd404912c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:38:31 +0200 Subject: [PATCH 42/76] Fix create_entity for a schema without property rows; test tuple coercion The Python unit test's fake host omits the properties list the real host always supplies. The coercion tolerates its absence, and a new test covers tuples for point properties and lists of them. The previous commit slipped past the Python tests because the gate did not include them. Co-Authored-By: Claude Sonnet 5 --- plugins/opencad-python/src/document_model.py | 2 +- .../opencad-python/tests/test_document_model.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 7c964ef80..19eadefff 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -8,7 +8,7 @@ def _coerce_points(kind, properties): """Let create_entity take (x, y, z) tuples for point properties, as transaction edits already do, by using the property types in the schema.""" schema = ocs.entity_coverage(kind) - types = {row["name"]: row for row in schema["properties"]} if schema else {} + types = {row["name"]: row for row in schema.get("properties", [])} if schema else {} result = {} for name, value in properties.items(): row = types.get(name) diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index bbb746616..59aa8071f 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -65,6 +65,20 @@ class DocumentModelTests(unittest.TestCase): with self.assertRaises(ValueError): self.doc.create_entity("Line", handle=4) + def test_create_entity_accepts_coordinate_tuples_for_point_properties(self): + self.ocs.entity_coverage = lambda kind: {"kind": kind, "scope": "canvas", + "editable": [], "readable": [], "unmapped": [], "properties": [ + {"name": "start", "type": "Vector3", "sequence": False}, + {"name": "end", "type": "Vector3", "sequence": False}, + {"name": "corners", "type": "Vector2", "sequence": True}, + {"name": "knots", "type": "f64", "sequence": True}]} + self.doc.create_entity("Line", start=(0, 0, 0), end=(10, 5, 0), + corners=[(1, 2), (3, 4)], knots=[0, 0, 1]) + self.assertEqual(self.calls[-1], ("add", {"kind": "Line", + "start": {"x": 0.0, "y": 0.0, "z": 0.0}, "end": {"x": 10.0, "y": 5.0, "z": 0.0}, + "corners": [{"x": 1.0, "y": 2.0}, {"x": 3.0, "y": 4.0}], + "knots": [0, 0, 1]})) + def test_coverage_and_read_only_entity(self): self.assertEqual(len(self.doc.entities), 2) self.assertEqual([entity.handle for entity in self.doc.entities], [1, 2]) From 9cd6653a58ecf406e0a6fac6a3a898ca2724646e Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:47:22 +0200 Subject: [PATCH 43/76] Complete Surface: plane surfaces, extrusions, isolines and moves Adds SurfaceFromProfile and Extrude kernel operations (doc.solids.surface and doc.solids.extrude), accepts surfaces in Transform, and maps Surface with editable isolines. Fixes a real bug found by the test: the surface kind field overwrote the entity-type kind key in Python dicts, so it is exposed as surface_kind, with a catalog test guarding against name collisions. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 14 + crates/ocs_plugin_api/src/entity_coverage.rs | 37 +++ crates/ocs_plugin_api/src/host.rs | 5 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 2 + docs/cadkernel-body-path.md | 2 +- docs/plugin-host-model-completion-plan.md | 7 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 37 ++- plugins/opencad-python/src/document_model.py | 12 + plugins/opencad-python/src/ocs_module.rs | 46 ++- src/app/plugin_host.rs | 302 ++++++++++++++++-- 14 files changed, 441 insertions(+), 34 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 1d60b1952..8da98a6f8 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -793,6 +793,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("Helix", _) | ("RasterImage", _) | ("Wipeout", _) + | ("Surface", "u_isolines" | "v_isolines") | ("Ole2Frame", _) | ("Underlay", _) | ("Viewport", _) diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index c1a882a9b..2d7bfc98b 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -502,6 +502,11 @@ ], "Region": [ "layer" + ], + "Surface": [ + "layer", + "u_isolines", + "v_isolines" ] }, "readable": { @@ -627,6 +632,12 @@ "uid", "point_of_reference", "history_handle" + ], + "Surface": [ + "surface_kind", + "modeler_format_version", + "point_of_reference", + "history_handle" ] }, "aliases": { @@ -643,6 +654,9 @@ "MText": { "text": "value", "insertion": "insertion_point" + }, + "Surface": { + "surface_kind": "kind" } }, "synthetic": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index a92f6716d..e36ded41d 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1245,6 +1245,24 @@ fn validate_ole_frame( Ok(()) } +/// Surface: only the display isoline density is scriptable; the geometry lives +/// in the ACIS payload that the geometry kernel owns. +#[cfg(feature = "host")] +fn validate_surface( + old: Option<&crate::host::acadrust::entities::Surface>, + new: &crate::host::acadrust::entities::Surface, +) -> Result<(), String> { + if old.is_some_and(|old| old.u_isolines == new.u_isolines && old.v_isolines == new.v_isolines) { + return Ok(()); + } + for (name, value) in [("u_isolines", new.u_isolines), ("v_isolines", new.v_isolines)] { + if !(0..=200).contains(&value) { + return Err(format!("Surface.{name} must be between 0 and 200")); + } + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -1371,6 +1389,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } } EntityType::Ole2Frame(value) => validate_ole_frame(None, value)?, + EntityType::Surface(value) => validate_surface(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1745,6 +1764,7 @@ pub fn validate_entity_mutation( return Err("Ole2Frame embedded storage cannot change".into()); } } + (EntityType::Surface(old), EntityType::Surface(new)) => validate_surface(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -2486,6 +2506,20 @@ mod tests { } } + #[test] + fn exposed_property_names_are_unique_per_kind() { + // A field named like a base key (`kind`, `handle`, `layer`, ...) would + // overwrite it in every Python dict; such fields must be renamed. + let catalog: EntityCoverageCatalog = + serde_json::from_str(get_embedded_entity_coverage_json()).unwrap(); + for entry in &catalog.entity_kinds { + let mut seen = std::collections::HashSet::new(); + for property in entry.properties.iter().filter(|p| p.model_access != ModelAccess::Unmapped) { + assert!(seen.insert(property.name.clone()), "{}: duplicate exposed name {}", entry.kind, property.name); + } + } + } + #[test] fn unsupported_kinds_have_no_editable_properties() { let catalog: EntityCoverageCatalog = @@ -2535,6 +2569,7 @@ mod tests { | "Solid3D" | "Body" | "Region" + | "Surface" ) { assert!( @@ -2824,6 +2859,8 @@ mod tests { "Ole2Frame.lower_right_corner".to_owned(), "Ole2Frame.lock_aspect".to_owned(), "Ole2Frame.is_paper_space".to_owned(), + "Surface.u_isolines".to_owned(), + "Surface.v_isolines".to_owned(), "Underlay.underlay_type".to_owned(), "Underlay.definition_handle".to_owned(), "Underlay.insertion_point".to_owned(), diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index ec59c428e..414487e4c 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -90,6 +90,11 @@ pub enum SolidOperation { /// `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, delete_source: bool }, + /// Build a plane surface from one closed planar profile entity. + SurfaceFromProfile { source: Handle, layer: Option, 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, delete_source: bool }, } /// Value of a host-managed drafting or document setting exposed to plugins. diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 740aa9773..94b023acd 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -115,6 +115,8 @@ mod tests { }, SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] }, SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), layer: None, delete_source: true }, + 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::(&bytes).unwrap(), diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md index ba6ad1e74..2bffcfde8 100644 --- a/docs/cadkernel-body-path.md +++ b/docs/cadkernel-body-path.md @@ -136,4 +136,4 @@ is refused with a clear message instead of being rewritten. Phase 1 alone would move Solid3D from blocked to complete-in-OCS. -**Status (2026-09-20): phase 1 is done.** `HostApi::solid_operation` (create primitive and rigid transform), the `doc.solids` Python API and `audit_python_solid3d_lifecycle_over_real_ipc` are in; Solid3D is complete, Body moves through the same channel. Design B was implemented as recommended. Region creation from a closed profile followed (`RegionFromProfile`). Booleans and Surface remain. +**Status (2026-09-20): phase 1 is done.** `HostApi::solid_operation` (create primitive and rigid transform), the `doc.solids` Python API and `audit_python_solid3d_lifecycle_over_real_ipc` are in; Solid3D is complete, Body moves through the same channel. Design B was implemented as recommended. Region creation from a closed profile followed (`RegionFromProfile`). Plane surfaces and extrusions (`SurfaceFromProfile`, `Extrude`) followed. Only booleans remain, plus lofted, revolved, swept and NURBS surfaces. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index c3b7e0567..c46764cce 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **41 of 43** canvas kinds: 38 +v7. The Python feature has explicit writes for **42 of 43** canvas kinds: 39 are creatable (the legacy `Polyline`, `Ole2Frame` and `Body` are update-only). This is **not** 41 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D and Region pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D, Region and Surface pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The remaining two canvas kinds have only `layer`: SectionSymbol (blocked) and Surface (unblocked by design) +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The remaining canvas kind, SectionSymbol, has only `layer` and is recorded as blocked writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -179,3 +179,4 @@ exact queue above and update statuses with evidence after each increment. - Investigated the cadkernel path for the ACIS kinds ([cadkernel-body-path.md](cadkernel-body-path.md)). The earlier "blocked" reasoning was overstated: OCS already builds primitives, transforms bodies and runs booleans through cadkernel, and `solid_to_sat` plus `kernel_acis_body` verify each result lifts back with no loss. Two `#[ignore]` spike tests measured exact volumes and DWG and DXF round trips for a box, moved, rotated and mirrored copies, three booleans, four other primitives and a `Body` entity. The real gap is an operation channel from a script to that pipeline; the note recommends an additive `solid_operation` host method and a phased plan (Solid3D create and transform, then booleans and Body, then Region, then Surface). Booleans took about 23 s each in a debug build and need release timing first. Region, Body, Solid3D and Surface move from blocked to unblocked-by-design; SectionSymbol stays blocked. - Built the cadkernel operation channel and completed `Solid3D` (create and rigid move). `HostApi::solid_operation` is additive (default "unsupported"), so older hosts and plugins are unaffected; it travels as `PluginRequest::SolidOperation` and `PluginResponse::SolidResult`, and the host records its own undo step only after validation so a refused operation leaves nothing behind. The Python surface is `doc.solids.*`. Every result is verified lossless by the kernel path before it is accepted and a payload that will not lift is refused untouched. `Body` moves through the same channel and `Region` is accepted but untested. Booleans, Region creation from curves and Surface remain, in that order; booleans wait on release-build timing. Solid3D, Body and Region are now mapped read-only with the opaque payload excluded and preserved. - Completed `Region`. `SolidOperation::RegionFromProfile` reuses the REGION command's kernel calls and `doc.solids.region` exposes it; the lifecycle test creates regions from real circle and polyline entities and covers deletion of the source, refusals, a move, both formats and undo. Along the way `create_entity` now accepts `(x, y, z)` tuples for point properties (decided from the schema's property types) and every generated float setter accepts Python ints, so `create_entity('Circle', center=(0, 0, 0), radius=5)` works. Left: booleans (release timing first), holes and open-curve merging for regions, and `Surface`. +- Completed `Surface` for planes, extruded open profiles, isolines and rigid moves. `SurfaceFromProfile` and `Extrude` join the kernel operations (`doc.solids.surface` and `doc.solids.extrude`); `Extrude` also gives Solid3D extrusions from closed profiles, checked against analytic volumes. The test found and fixed a real bug: Surface's `kind` field silently overwrote the entity-type `kind` key in Python dicts, so it is exposed as `surface_kind`, and a new uniqueness test guards the whole catalog. Transform now accepts surfaces (a plane stays a plane; other kinds turn generic). Left: booleans (release timing first), regions with holes, and lofted, revolved, swept and NURBS surfaces. SectionSymbol is the only unmapped kind. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index a35d9b2e6..75b6920bc 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **41 of 43 canvas kinds have explicit geometry writes** (38 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **28 kinds are complete**, and 2 remain layer-only (SectionSymbol blocked, Surface unblocked by design). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **42 of 43 canvas kinds have explicit geometry writes** (39 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **29 kinds are complete**, and 1 remains layer-only (SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -47,7 +47,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Region | **Complete (create from a profile + rigid move)** | New `SolidOperation::RegionFromProfile` and `doc.solids.region(profile, layer=None, delete_source=False)`. The host builds the region exactly as the REGION command does (`presspull_model::profile_geometry` then `cadkernel::brep::planar_region`), verifies it with `solid_to_sat`, puts it on `layer` or on the profile's layer, and erases the profile only when `delete_source` is set. One closed planar profile per call (a circle, ellipse, closed polyline, spline and so on); an open profile, a ray, a region, a missing entity or an empty layer is refused with a message before anything changes. Rigid moves go through the same `doc.solids.transform` channel as Solid3D. `audit_python_region_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: regions from a circle (source kept), a closed rectangle (source deleted) and a second circle on its own layer with exact extents, canvas geometry, six refusals that change nothing and record no undo step, a move with the exact shifted extent, DWG and DXF reopen with the payload lifting and the same extent, a reopened region moving again, and undo restoring the region exactly. Not covered: regions with holes and merging open curves into a boundary (the REGION command's second path), and AutoCAD acceptance of the payload. | | Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. | | Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: booleans (about 23 s each in a debug build; release timing needed first), solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. | -| Surface | **Blocked, unblocked by design** | Not mapped yet, and the most involved: a surface carries `surface_data` beside `acis_data`, and only a plane keeps its kind when regenerated. Plan: planes first, then extruded and revolved from a profile. See [cadkernel-body-path.md](cadkernel-body-path.md). | +| Surface | **Complete (plane surface, extruded open profile, isolines, rigid move)** | New `SolidOperation::SurfaceFromProfile` and `SolidOperation::Extrude`, exposed as `doc.solids.surface(profile)` and `doc.solids.extrude(profile, direction)`. A closed planar profile becomes a `Plane` surface (built as the region body, so it keeps the plane kind); a profile extruded along a nonzero direction becomes a Solid3D when it is closed and a generic Surface when it is open, using OCS's own `extrusion_body`. Every payload is verified lossless by `solid_to_sat`, and rigid moves (now accepted for surfaces too) keep a plane a plane and turn any other kind generic, as OCS does after an edit. Python sees `surface_kind`, `modeler_format_version`, `point_of_reference` and `history_handle` read-only and `u_isolines` and `v_isolines` writable (0 to 200); the payload, wires, silhouettes and the kind-specific `surface_data` are excluded and preserved. The kind field is exposed as `surface_kind` because a bare `kind` key names the entity type in every Python dict; the new `exposed_property_names_are_unique_per_kind` test guards against that class of collision. `audit_python_surface_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: two plane surfaces (one on its own layer), a surface from an open polyline extrusion, three extruded solids checked against their analytic volumes (a box exactly, curved ones within 2 percent), `delete_source` erasing only the intended profile, canvas geometry, nine refusals that change nothing and record no undo step, an isoline transaction with four rejections (500, negative, the read-only kind, an excluded key), moves with exact extents, DWG and DXF reopen with lifting payloads, matching extents, kept isolines and the plane kind, a reopened surface moving again, and exact undo. Not covered: lofted, revolved, swept and NURBS surfaces (a script cannot create them; an existing one is preserved and turns generic on a move), parametric history, and AutoCAD acceptance. | | Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. | After each increment, update the row with test names, fixture paths, exact diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 1b6d4598f..8bb6e0ca6 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 41 geometry converters. Other writable properties +canvas kinds outside its 42 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -92,7 +92,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 41-kind generated schema it contains only handle, kind, and layer; +outside the 42-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 217e08f97..7f594836e 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 41 mapped kinds. +the 42 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 7accbdf77..014a750b6 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -40,7 +40,8 @@ "Ole2Frame", "Solid3D", "Body", - "Region" + "Region", + "Surface" ], "manual_kinds": [ "Dimension" @@ -891,6 +892,40 @@ "read_only": true } } + }, + "Surface": { + "update_only": true, + "update_only_message": "Surface is built by the geometry kernel: use doc.solids.surface(profile) for a plane surface or doc.solids.extrude(open_profile, direction) for an extruded one; a script never writes the ACIS payload", + "fields": { + "acis_data": { + "exclude": true, + "_comment": "Opaque ACIS payload; never rewritten by a script." + }, + "surface_data": { + "exclude": true, + "_comment": "Kind-specific sweep/loft parameters and embedded profiles; preserved untouched." + }, + "wires": { + "exclude": true + }, + "silhouettes": { + "exclude": true + }, + "kind": { + "read_only": true, + "python_name": "surface_kind", + "_comment": "Renamed: a bare `kind` key names the entity type in every Python dict." + }, + "modeler_format_version": { + "read_only": true + }, + "point_of_reference": { + "read_only": true + }, + "history_handle": { + "read_only": true + } + } } } } diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 19eadefff..0d0643e53 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -146,6 +146,18 @@ class _Solids: handle = profile.handle if isinstance(profile, _Entity) else int(profile) return self._document.entities[ocs.solid_region(handle, layer, bool(delete_source))] + def surface(self, profile, layer=None, delete_source=False): + """Make a plane surface from one closed planar profile.""" + handle = profile.handle if isinstance(profile, _Entity) else int(profile) + return self._document.entities[ocs.solid_surface(handle, layer, bool(delete_source))] + + def extrude(self, profile, direction, layer=None, delete_source=False): + """Extrude a planar profile along `direction`: a Solid3D when the + profile is closed, a Surface when it is open.""" + handle = profile.handle if isinstance(profile, _Entity) else int(profile) + vector = [float(v) for v in direction] + return self._document.entities[ocs.solid_extrude(handle, vector, layer, bool(delete_source))] + def transform(self, entity, matrix): """Apply a column-major 4x4 rigid transform (16 numbers) in place.""" handle = entity.handle if isinstance(entity, _Entity) else int(entity) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index dcfdb140b..471761b45 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -475,6 +475,50 @@ mod ocs { .map_err(|error| vm.new_runtime_error(format!("ocs.solid_region: {error}"))) } + /// Build a plane surface from a closed planar profile entity. + #[pyfunction] + fn solid_surface(source: u64, layer: Option, delete_source: bool, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::SurfaceFromProfile { + source: Handle::new(source), + layer, + delete_source, + }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_surface: {error}"))) + } + + /// Extrude a planar profile: a solid if closed, a surface if open. + #[pyfunction] + fn solid_extrude( + source: u64, + direction: Vec, + layer: Option, + delete_source: bool, + vm: &VirtualMachine, + ) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let direction: [f64; 3] = direction.try_into().map_err(|_| { + vm.new_value_error("ocs.solid_extrude: the direction needs 3 numbers".to_owned()) + })?; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::Extrude { + source: Handle::new(source), + direction, + layer, + delete_source, + }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_extrude: {error}"))) + } + /// Apply a column-major 4x4 rigid transform to a solid, body or region. #[pyfunction] fn solid_transform(handle: u64, matrix: Vec, vm: &VirtualMachine) -> PyResult { @@ -4905,7 +4949,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 41); + assert_eq!(emitted.len(), 42); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 9db23fa3c..899c0cc7d 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -621,22 +621,10 @@ impl<'a> HostSession<'a> { Ok(handle) } SolidOperation::RegionFromProfile { source, layer, delete_source } => { - if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { - return Err("layer name is empty".into()); - } - let entity = self - .document() - .get_entity(source) - .cloned() - .ok_or_else(|| format!("entity {source:?} does not exist"))?; + let (entity, plane, loops, closed, layer) = self.profile_source(source, layer, delete_source)?; if matches!(entity, EntityType::Region(_)) { return Err("the source is already a region".into()); } - if delete_source && self.app.tabs[self.tab].scene.is_layer_locked(source) { - return Err(format!("entity {source:?} is on a locked layer")); - } - let (plane, loops, closed) = crate::scene::model::presspull_model::profile_geometry(&entity) - .ok_or("the source is not a planar profile a region can be made from")?; if !closed { return Err("the profile must be closed".into()); } @@ -649,17 +637,60 @@ impl<'a> HostSession<'a> { acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); region.wires = model::edge_wires(&body); region.set_sat_document(&sat); - region.common.layer = layer.unwrap_or_else(|| entity.common().layer.clone()); - self.push_undo("Create region"); - let handle = self.add_entity(EntityType::Region(region)); - if handle.is_null() { - return Err("the region could not be added".into()); + region.common.layer = layer; + self.commit_profile_result(EntityType::Region(region), source, delete_source, "Create region") + } + SolidOperation::SurfaceFromProfile { source, layer, delete_source } => { + let (entity, plane, loops, closed, layer) = self.profile_source(source, layer, delete_source)?; + if matches!(entity, EntityType::Region(_)) { + return Err("the source is already a region".into()); } - if delete_source { - self.app.tabs[self.tab].scene.erase_entities(&[source]); - self.publish_document_view(); + if !closed { + return Err("the profile must be closed".into()); } - Ok(handle) + let body = cadkernel::brep::planar_region(plane, &loops) + .ok_or("the geometry kernel could not build a surface from this profile")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("the surface could not be exported losslessly")?; + let mut surface = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane); + surface.point_of_reference = + acadrust::types::Vector3::new(plane.origin[0], plane.origin[1], plane.origin[2]); + surface.wires = model::edge_wires(&body); + surface.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string()); + surface.common.layer = layer; + self.commit_profile_result(EntityType::Surface(surface), source, delete_source, "Create surface") + } + SolidOperation::Extrude { source, direction, layer, delete_source } => { + if direction.iter().any(|v| !v.is_finite()) { + return Err("extrusion direction must be finite".into()); + } + if direction.iter().map(|v| v * v).sum::().sqrt() <= 1e-9 { + return Err("extrusion direction must be nonzero".into()); + } + let (entity, _plane, _loops, closed, layer) = self.profile_source(source, layer, delete_source)?; + if matches!(entity, EntityType::Region(_)) { + return Err("extrude a curve profile; a region cannot be extruded here".into()); + } + let body = crate::scene::model::presspull_model::extrusion_body(&entity, direction) + .ok_or("the geometry kernel could not extrude this profile in that direction")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&body) + .ok_or("the extrusion could not be exported losslessly")?; + let result = if closed { + let mut solid = acadrust::entities::Solid3D::new(); + solid.wires = model::edge_wires(&body); + solid.set_sat_document(&sat); + solid.common.layer = layer; + EntityType::Solid3D(solid) + } else { + // An open profile sweeps to a surface. The generic kind + // carries only the payload, as OCS does after a boolean. + let mut surface = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Generic); + surface.wires = model::edge_wires(&body); + surface.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string()); + surface.common.layer = layer; + EntityType::Surface(surface) + }; + self.commit_profile_result(result, source, delete_source, "Extrude profile") } SolidOperation::Transform { handle, matrix } => { if matrix.iter().any(|v| !v.is_finite()) { @@ -705,7 +736,25 @@ impl<'a> HostSession<'a> { EntityType::Solid3D(value) => retarget!(value), EntityType::Body(value) => retarget!(value), EntityType::Region(value) => retarget!(value), - _ => return Err("transform applies to Solid3D, Body and Region entities".into()), + EntityType::Surface(value) => { + let body = crate::scene::convert::solid3d_tess::kernel_acis_body(&value.acis_data) + .ok_or("this surface's payload cannot be lifted losslessly, so it is left untouched")?; + let moved = model::by_matrix(&body, matrix) + .ok_or("the geometry kernel refused this transform")?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&moved) + .ok_or("the moved surface could not be exported losslessly")?; + value.wires = model::edge_wires(&moved); + value.silhouettes.clear(); + value.history_handle = None; + value.acis_data = acadrust::entities::AcisData::from_sat(&sat.to_sat_string()); + // A moved plane is still a plane; a swept surface no longer + // matches its stored sweep parameters, so it becomes generic. + if value.kind != acadrust::entities::SurfaceKind::Plane { + value.kind = acadrust::entities::SurfaceKind::Generic; + value.surface_data = acadrust::entities::SurfaceData::Generic; + } + } + _ => return Err("transform applies to Solid3D, Body, Region and Surface entities".into()), } self.push_undo("Transform solid"); if !self.update_entity(entity) { @@ -716,6 +765,56 @@ impl<'a> HostSession<'a> { } } + /// Look up a profile entity and its exact planar loops for the kernel. + /// Returns the entity, its plane, loops and closed flag, and the layer the + /// result should use (`layer`, or the profile's own). + #[allow(clippy::type_complexity)] + fn profile_source( + &self, + source: Handle, + layer: Option, + delete_source: bool, + ) -> Result<(EntityType, cadkernel::space::Plane, Vec>, bool, String), String> { + if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { + return Err("layer name is empty".into()); + } + let entity = self + .document() + .get_entity(source) + .cloned() + .ok_or_else(|| format!("entity {source:?} does not exist"))?; + if delete_source && self.app.tabs[self.tab].scene.is_layer_locked(source) { + return Err(format!("entity {source:?} is on a locked layer")); + } + if matches!(entity, EntityType::Solid3D(_) | EntityType::Body(_) | EntityType::Surface(_)) { + return Err("the source must be a curve profile, not an existing solid or surface".into()); + } + let (plane, loops, closed) = crate::scene::model::presspull_model::profile_geometry(&entity) + .ok_or("the source is not a planar profile")?; + let layer = layer.unwrap_or_else(|| entity.common().layer.clone()); + Ok((entity, plane, loops, closed, layer)) + } + + /// Record one undo step, add `result` and optionally erase the profile. + fn commit_profile_result( + &mut self, + result: EntityType, + source: Handle, + delete_source: bool, + label: &str, + ) -> Result { + self.push_undo(label); + let handle = self.add_entity(result); + if handle.is_null() { + return Err("the result could not be added".into()); + } + if delete_source { + self.app.tabs[self.tab].scene.erase_entities(&[source]); + self.publish_document_view(); + } + Ok(handle) + } + pub fn push_undo(&mut self, label: &str) { self.app.push_undo_snapshot(self.tab, label); } @@ -5071,6 +5170,163 @@ mod tests { assert_eq!(app.tabs[0].scene.document.get_entity(list[0]), Some(&before), "undoing the move restores the region exactly"); } + #[test] + fn audit_python_surface_lifecycle_over_real_ipc() { + use crate::scene::convert::solid3d_tess::kernel_acis_body; + use crate::scene::model::solid_model as sm; + use acadrust::entities::SurfaceKind; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let sorted = |mut list: Vec| { list.sort_by_key(|h| h.value()); list }; + let surfaces_in = |document: &CadDocument| sorted(document.entities().filter_map(|e| match e { + EntityType::Surface(s) => Some(s.common.handle), _ => None }).collect()); + let solids_in = |document: &CadDocument| sorted(document.entities().filter_map(|e| match e { + EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect()); + let body_of = |document: &CadDocument, handle: Handle| match document.get_entity(handle) { + Some(EntityType::Surface(s)) => kernel_acis_body(&s.acis_data).expect("surface payload lifts losslessly"), + Some(EntityType::Solid3D(s)) => kernel_acis_body(&s.acis_data).expect("solid payload lifts losslessly"), + other => panic!("no surface or solid {handle:?}: {other:?}"), + }; + let extent_in = |document: &CadDocument, handle: Handle| sm::extent(&body_of(document, handle)).unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + let kind_in = |document: &CadDocument, handle: Handle| match document.get_entity(handle) { + Some(EntityType::Surface(s)) => s.kind, other => panic!("no surface {handle:?}: {other:?}") }; + + // C: plane surfaces and extrusions from real profile entities. + let script = std::env::temp_dir().join(format!("ocs_surface_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "doc = ocs.active_document\n", + "s = doc.solids\n", + "circle = doc.create_entity('Circle', center=(0, 0, 0), radius=3)\n", + "rect = doc.create_entity('LwPolyline', is_closed=True, vertices=[{'location': {'x': 20.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 0.0}}, {'location': {'x': 30.0, 'y': 6.0}}, {'location': {'x': 20.0, 'y': 6.0}}])\n", + "open_path = doc.create_entity('LwPolyline', vertices=[{'location': {'x': 50.0, 'y': 0.0}}, {'location': {'x': 54.0, 'y': 0.0}}, {'location': {'x': 54.0, 'y': 4.0}}])\n", + "temp = doc.create_entity('Circle', center=(70, 0, 0), radius=2)\n", + "s.surface(circle)\n", + "s.surface(rect, layer='SURFACES')\n", + "s.extrude(rect, (0, 0, 4))\n", + "s.extrude(circle, (0, 0, 5))\n", + "s.extrude(open_path, (0, 0, 3))\n", + "s.extrude(temp, (0, 0, 2), delete_source=True)\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let surfaces = surfaces_in(host.document()); + let solids = solids_in(host.document()); + assert_eq!((surfaces.len(), solids.len()), (3, 3), "{}", last(&host)); + // surfaces[0] plane of the circle, [1] plane of the rectangle, [2] extruded open path. + assert_eq!((kind_in(host.document(), surfaces[0]), kind_in(host.document(), surfaces[1]), kind_in(host.document(), surfaces[2])), + (SurfaceKind::Plane, SurfaceKind::Plane, SurfaceKind::Generic)); + let (low, high) = extent_in(host.document(), surfaces[0]); + assert!(close(low[0], -3.0) && close(high[1], 3.0) && close(low[2], 0.0) && close(high[2], 0.0), "{low:?} {high:?}"); + let (low, high) = extent_in(host.document(), surfaces[2]); + assert!(close(low[0], 50.0) && close(high[0], 54.0) && close(low[2], 0.0) && close(high[2], 3.0), "open path extrusion {low:?} {high:?}"); + assert_eq!(host.document().get_entity(surfaces[1]).unwrap().common().layer, "SURFACES"); + let pi = std::f64::consts::PI; + for (index, expected, tolerance) in [(0, 240.0, 1e-9), (1, pi * 9.0 * 5.0, 0.02), (2, pi * 4.0 * 2.0, 0.02)] { + let volume = sm::volume(&body_of(host.document(), solids[index])); + assert!((volume - expected).abs() <= tolerance * expected, "solid {index}: {volume} vs {expected}"); + } + assert_eq!(host.document().entities().filter(|e| matches!(e, EntityType::Circle(_))).count(), 1, + "delete_source removed only the extruded temporary circle"); + assert!(!host.app.tabs[0].scene.wire_models_for(&[surfaces[0]]).is_empty(), "no canvas geometry"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", surfaces[0].value())); + assert!(last(&host).contains("Surface")); + + // V: refusals change nothing and leave no undo step. + let ray = host.add_entity(EntityType::Ray(acadrust::entities::Ray::default())); + let circle = host.document().entities().find_map(|e| match e { EntityType::Circle(c) => Some(c.common.handle), _ => None }).unwrap(); + let counts = (host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()); + for (command, message) in [ + (format!("ocs.active_document.solids.extrude({}, (0, 0, 0))", circle.value()), "nonzero"), + (format!("ocs.active_document.solids.extrude({}, (float('nan'), 0, 1))", circle.value()), "must be finite"), + (format!("ocs.active_document.solids.extrude({}, (0, 0, 1))", ray.value()), "planar profile"), + (format!("ocs.active_document.solids.extrude({}, (0, 0, 1))", solids[0].value()), "curve profile"), + (format!("ocs.active_document.solids.surface({})", surfaces[0].value()), "curve profile"), + (format!("ocs.active_document.solids.surface({})", ray.value()), "planar profile"), + (format!("ocs.active_document.solids.surface({}, layer='')", circle.value()), "layer name is empty"), + ("ocs.active_document.solids.surface(999999)".to_string(), "does not exist"), + ("ocs.solid_extrude(1, [1.0], None, False)".to_string(), "3 numbers"), + ] { + dispatch(&mut host, &format!("PY_EVAL {command}")); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!((host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()), counts, + "refusals change nothing and record no undo step"); + + // E: isoline density is scriptable and validated; kind and payload are not. + let plane = surfaces[0]; + let plane_before = host.document().get_entity(plane).unwrap().clone(); + let script = std::env::temp_dir().join(format!("ocs_surface_edit_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "e = doc.entities[{}]\n", + "with doc.transaction('Edit surface'):\n", + " e.u_isolines = 8\n", + " e.v_isolines = 6\n", + ), plane.value())).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let Some(EntityType::Surface(edited)) = host.document().get_entity(plane) else { unreachable!() }; + assert_eq!((edited.u_isolines, edited.v_isolines, edited.kind), (8, 6, SurfaceKind::Plane), "{}", last(&host)); + let edited_state = host.document().get_entity(plane).unwrap().clone(); + for (patch, message) in [("'u_isolines':500", "between 0 and 200"), ("'surface_kind':'Extruded'", "read-only"), + ("'surface_data':{}", "outside the editable schema"), ("'v_isolines':-1", "between 0 and 200")] { + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Reject surface', [{{'handle':{}, {patch}}}])", plane.value())); + assert_eq!(host.document().get_entity(plane), Some(&edited_state), "{patch}"); + assert!(last(&host).contains(message), "{patch}: {}", last(&host)); + } + // Moves keep a plane a plane and turn a swept surface generic. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (10, 5, 0)).handle", plane.value())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (0, 0, 7)).handle", surfaces[2].value())); + assert_eq!(kind_in(host.document(), plane), SurfaceKind::Plane); + let (low, high) = extent_in(host.document(), plane); + assert!(close(low[0], 7.0) && close(high[0], 13.0) && close(low[1], 2.0) && close(high[1], 8.0), "{low:?} {high:?}"); + let (low, _) = extent_in(host.document(), surfaces[2]); + assert!(close(low[2], 7.0), "{low:?}"); + + // W: both formats reopen surfaces that lift with the same extents, keep + // the isolines, and a reopened surface moves again. + let dwg = crate::io::load_bytes("surface.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("surface.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + let moved = extent_in(host.document(), plane); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + assert_eq!((surfaces_in(document).len(), solids_in(document).len()), (3, 3), "{format}"); + let reopened = extent_in(document, plane); + for axis in 0..3 { + assert!(close(reopened.0[axis], moved.0[axis]) && close(reopened.1[axis], moved.1[axis]), "{format} extent"); + } + let Some(EntityType::Surface(back)) = document.get_entity(plane) else { unreachable!() }; + assert_eq!((back.u_isolines, back.v_isolines), (8, 6), "{format} isolines"); + assert_eq!(back.kind, SurfaceKind::Plane, "{format} keeps the plane kind"); + let again = host.add_entity(EntityType::Surface(back.clone())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value())); + assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit"); + } + + // D and U. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", surfaces[1].value())); + assert!(host.document().get_entity(surfaces[1]).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(surfaces[1]).is_some(), "undo restores the deleted surface"); + // Two re-edits of reopened copies, two moves, and the isoline edit come before. + app.undo_steps(2 + 2 + 1); + assert_eq!(app.tabs[0].scene.document.get_entity(plane), Some(&plane_before), "undoing the edits restores the surface exactly"); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 69047549e7add49e0d6acdcff34e281eb2aaf22a Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:50:38 +0200 Subject: [PATCH 44/76] Record release-build boolean timings and a recommendation Times brep::combine in release against the pinned cadkernel: about 1-2 s for curved operands, 1-2 ms for planar, versus 23 s in debug. Records the kernel's frequent refusals, the host-thread and 30 s script-budget implications, and recommends a synchronous Boolean operation that surfaces refusals. Co-Authored-By: Claude Sonnet 5 --- docs/cadkernel-body-path.md | 67 +++++++++++++++++++++-- docs/plugin-host-model-completion-plan.md | 1 + 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md index 2bffcfde8..ebcdc59c2 100644 --- a/docs/cadkernel-body-path.md +++ b/docs/cadkernel-body-path.md @@ -109,10 +109,10 @@ is refused with a clear message instead of being rewritten. ## Risks and open questions -1. **Boolean speed.** About 23 s each in a debug build. Time them in release - before deciding whether they can run on the host thread (the GUI BOOLEAN - command has the same cost) or need a background task. Everything else is - instant. +1. **Boolean speed.** Measured in release, see "Boolean timings" below: about + 1 to 2.2 s for a box and a cylinder, 0.2 s for two spheres and 1 to 2 ms for + planar-only operands, against 23 s in a debug build. The kernel also refuses + many curved cases, which matters more than speed. 2. **Interoperability cannot be verified here.** `solid_to_sat` is verified by OCS's own lifter. Whether AutoCAD accepts the SAT/SAB that OCS writes is not testable without AutoCAD, so completion should be scoped as "OCS round trip", @@ -137,3 +137,62 @@ is refused with a clear message instead of being rewritten. Phase 1 alone would move Solid3D from blocked to complete-in-OCS. **Status (2026-09-20): phase 1 is done.** `HostApi::solid_operation` (create primitive and rigid transform), the `doc.solids` Python API and `audit_python_solid3d_lifecycle_over_real_ipc` are in; Solid3D is complete, Body moves through the same channel. Design B was implemented as recommended. Region creation from a closed profile followed (`RegionFromProfile`). Plane surfaces and extrusions (`SurfaceFromProfile`, `Extrude`) followed. Only booleans remain, plus lofted, revolved, swept and NURBS surfaces. + +## Boolean timings (release build) + +Measured 2026-09-20 in a scratch crate built with `--release` (opt-level 3) +against the same cadkernel revision OCS pins (`6f046af`, features `acis` and +`offset`), calling exactly what OCS's `solid_model::boolean_result` calls: +`brep::operation_tolerance` then `brep::combine`. A debug build of OCS is about +22 times slower, so booleans must never be exercised in a debug test run. The +scratch program is not in the repository; to repeat it, depend on cadkernel at +that revision and time `brep::combine` on `brep::make::{cuboid, cylinder, +sphere, torus}` operands. + +| Operation | Time | Result | +|---|---|---| +| box union / subtract / intersect cylinder (10x6x4 box, r=2 cylinder) | 1.05 to 1.08 s each, identical on repeat runs | ok | +| same, operands scaled 10x and 100x | 2.21 s and 2.21 s | ok | +| box union / subtract box (planar only) | 0.001 to 0.002 s | ok | +| sphere union sphere (overlapping) | 0.22 s | ok | +| sphere subtract sphere (overlapping) | 0.23 s | refused `CutRefused` | +| box subtract sphere (sphere cutting the box faces) | 1.28 s | refused `Coincident` | +| sphere union torus (disjoint) | under 1 ms | refused `NoClosedForm` | +| torus subtract cylinder through the ring | under 1 ms | refused `NoClosedForm` | +| drill a first cylinder hole in a box | 0.03 s | ok | +| drill a second hole in the drilled box | under 1 ms | refused `CutRefused` | + +What this means: + +- **Speed is acceptable.** A curved boolean costs 0.2 to 2.2 s, the same cost as + the GUI BOOLEAN command, and planar work is instant. The 23 s seen earlier was + a debug-build artifact. +- **It blocks the host thread.** The plugin's `PY_RUN` is a synchronous dispatch + and nested host requests run inline, so a script freezes the UI while a + boolean runs. One to two seconds per operation is tolerable; a script with + dozens is not. +- **The whole script has a budget.** A `Dispatch` call uses the 30 s default + timeout (`OCS_PLUGIN_CALL_TIMEOUT_SECS`, with a 10 s floor), and nested host + work counts against one deadline. Roughly fifteen curved booleans in a single + script would exceed it. +- **Refusals are the real limit.** The kernel says no often: coincident faces, a + cut it has no closed form for, torus operands, and, surprisingly, a second + cut in a body that was already drilled. Refusals are fast and leave the inputs + intact, which is the behavior the API already relies on, but it means booleans + will be a partial feature that fails with a reason, not a general one. +- **Timing is not proportional to size.** Scaling the operands 10x doubled the + time, 100x changed nothing more, so the cost is dominated by the surface + intersection setup rather than the geometry size. + +### Recommendation + +Add `SolidOperation::Boolean { first, second, operation, layer }` and run it +synchronously on the host thread, surfacing the kernel's refusal (`Coincident`, +`CutRefused`, `NoClosedForm`, ...) verbatim in the error and leaving both +operands untouched on failure. Build the result with the existing +`entity_with_boolean_body` path so it inherits the lossless verification. +Test it with **planar** operands (millisecond cost, safe in debug) plus one +`#[ignore]` curved case that documents the release-only runtime. Document the +30 s script budget and that chained cuts on a drilled body are often refused. +Do not move booleans to a background thread yet: it would need cancellation and +undo coordination for a cost that is one to two seconds. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index c46764cce..f64267fe3 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -180,3 +180,4 @@ exact queue above and update statuses with evidence after each increment. - Built the cadkernel operation channel and completed `Solid3D` (create and rigid move). `HostApi::solid_operation` is additive (default "unsupported"), so older hosts and plugins are unaffected; it travels as `PluginRequest::SolidOperation` and `PluginResponse::SolidResult`, and the host records its own undo step only after validation so a refused operation leaves nothing behind. The Python surface is `doc.solids.*`. Every result is verified lossless by the kernel path before it is accepted and a payload that will not lift is refused untouched. `Body` moves through the same channel and `Region` is accepted but untested. Booleans, Region creation from curves and Surface remain, in that order; booleans wait on release-build timing. Solid3D, Body and Region are now mapped read-only with the opaque payload excluded and preserved. - Completed `Region`. `SolidOperation::RegionFromProfile` reuses the REGION command's kernel calls and `doc.solids.region` exposes it; the lifecycle test creates regions from real circle and polyline entities and covers deletion of the source, refusals, a move, both formats and undo. Along the way `create_entity` now accepts `(x, y, z)` tuples for point properties (decided from the schema's property types) and every generated float setter accepts Python ints, so `create_entity('Circle', center=(0, 0, 0), radius=5)` works. Left: booleans (release timing first), holes and open-curve merging for regions, and `Surface`. - Completed `Surface` for planes, extruded open profiles, isolines and rigid moves. `SurfaceFromProfile` and `Extrude` join the kernel operations (`doc.solids.surface` and `doc.solids.extrude`); `Extrude` also gives Solid3D extrusions from closed profiles, checked against analytic volumes. The test found and fixed a real bug: Surface's `kind` field silently overwrote the entity-type `kind` key in Python dicts, so it is exposed as `surface_kind`, and a new uniqueness test guards the whole catalog. Transform now accepts surfaces (a plane stays a plane; other kinds turn generic). Left: booleans (release timing first), regions with holes, and lofted, revolved, swept and NURBS surfaces. SectionSymbol is the only unmapped kind. +- Timed booleans in a release build (details in [cadkernel-body-path.md](cadkernel-body-path.md)). A curved box-and-cylinder boolean takes about 1 to 2.2 s, overlapping spheres 0.2 s and planar operands 1 to 2 ms, against 23 s in a debug build. The kernel refuses many curved cases quickly with a reason (`Coincident`, `CutRefused`, `NoClosedForm`), including a second cut in an already drilled box. Recommendation: add a synchronous `SolidOperation::Boolean` that surfaces the refusal and leaves the operands untouched, test it with planar operands plus one ignored curved case, and note the 30 s per-script budget. No code changed in this step. From 5a39052397ad1f308d5853e19ccf30abd97b70d0 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 17:58:38 +0200 Subject: [PATCH 45/76] Add kernel booleans: union, subtract and intersect for Solid3D SolidOperation::Boolean runs synchronously, surfaces the kernel's refusal with a plain reason and leaves the operands untouched, consumes them on success unless keep_operands, and records one undo step. The lifecycle test uses planar boxes with exact volumes and covers chaining, refusals, both formats and undo; an ignored test shows a curved refusal reaching the script. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 21 ++ crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 7 + docs/cadkernel-body-path.md | 2 + docs/plugin-host-model-completion-plan.md | 1 + docs/plugin-host-model-coverage-ledger.md | 2 +- plugins/opencad-python/src/document_model.py | 20 ++ plugins/opencad-python/src/ocs_module.rs | 34 +++ src/app/plugin_host.rs | 234 +++++++++++++++++++ 8 files changed, 320 insertions(+), 1 deletion(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 414487e4c..3a18d1fb0 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -76,6 +76,15 @@ pub enum SolidPrimitive { 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, +} + /// 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)] @@ -95,6 +104,18 @@ pub enum SolidOperation { /// 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, 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. + Boolean { + first: Handle, + second: Handle, + operation: SolidBoolean, + layer: Option, + keep_operands: bool, + }, } /// Value of a host-managed drafting or document setting exposed to plugins. diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 94b023acd..8662e4888 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -115,6 +115,13 @@ mod tests { }, SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] }, SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), layer: None, delete_source: true }, + 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 }, ] { diff --git a/docs/cadkernel-body-path.md b/docs/cadkernel-body-path.md index ebcdc59c2..312e36960 100644 --- a/docs/cadkernel-body-path.md +++ b/docs/cadkernel-body-path.md @@ -196,3 +196,5 @@ Test it with **planar** operands (millisecond cost, safe in debug) plus one 30 s script budget and that chained cuts on a drilled body are often refused. Do not move booleans to a background thread yet: it would need cancellation and undo coordination for a cost that is one to two seconds. + +**Status (2026-09-20): implemented.** `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect` follow the recommendation above, with planar-operand lifecycle testing and an ignored curved refusal test. Every operation this note proposed is now built. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index f64267fe3..6008240c6 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -181,3 +181,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `Region`. `SolidOperation::RegionFromProfile` reuses the REGION command's kernel calls and `doc.solids.region` exposes it; the lifecycle test creates regions from real circle and polyline entities and covers deletion of the source, refusals, a move, both formats and undo. Along the way `create_entity` now accepts `(x, y, z)` tuples for point properties (decided from the schema's property types) and every generated float setter accepts Python ints, so `create_entity('Circle', center=(0, 0, 0), radius=5)` works. Left: booleans (release timing first), holes and open-curve merging for regions, and `Surface`. - Completed `Surface` for planes, extruded open profiles, isolines and rigid moves. `SurfaceFromProfile` and `Extrude` join the kernel operations (`doc.solids.surface` and `doc.solids.extrude`); `Extrude` also gives Solid3D extrusions from closed profiles, checked against analytic volumes. The test found and fixed a real bug: Surface's `kind` field silently overwrote the entity-type `kind` key in Python dicts, so it is exposed as `surface_kind`, and a new uniqueness test guards the whole catalog. Transform now accepts surfaces (a plane stays a plane; other kinds turn generic). Left: booleans (release timing first), regions with holes, and lofted, revolved, swept and NURBS surfaces. SectionSymbol is the only unmapped kind. - Timed booleans in a release build (details in [cadkernel-body-path.md](cadkernel-body-path.md)). A curved box-and-cylinder boolean takes about 1 to 2.2 s, overlapping spheres 0.2 s and planar operands 1 to 2 ms, against 23 s in a debug build. The kernel refuses many curved cases quickly with a reason (`Coincident`, `CutRefused`, `NoClosedForm`), including a second cut in an already drilled box. Recommendation: add a synchronous `SolidOperation::Boolean` that surfaces the refusal and leaves the operands untouched, test it with planar operands plus one ignored curved case, and note the 30 s per-script budget. No code changed in this step. +- Implemented `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect`, as recommended by the timing study. It runs synchronously, surfaces the kernel's refusal (`Coincident`, `CutRefused`, `NoClosedForm`) with a plain-language reason and the words "nothing was changed", consumes the operands unless `keep_operands`, and records one undo step. The lifecycle test uses planar boxes (millisecond cost, exact volumes, debug-safe) and an `#[ignore]` test documents a curved refusal. With this the kernel operations in the cadkernel note are all built. SectionSymbol is the only unmapped kind. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 75b6920bc..3d3444c37 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -46,7 +46,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. | | Region | **Complete (create from a profile + rigid move)** | New `SolidOperation::RegionFromProfile` and `doc.solids.region(profile, layer=None, delete_source=False)`. The host builds the region exactly as the REGION command does (`presspull_model::profile_geometry` then `cadkernel::brep::planar_region`), verifies it with `solid_to_sat`, puts it on `layer` or on the profile's layer, and erases the profile only when `delete_source` is set. One closed planar profile per call (a circle, ellipse, closed polyline, spline and so on); an open profile, a ray, a region, a missing entity or an empty layer is refused with a message before anything changes. Rigid moves go through the same `doc.solids.transform` channel as Solid3D. `audit_python_region_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: regions from a circle (source kept), a closed rectangle (source deleted) and a second circle on its own layer with exact extents, canvas geometry, six refusals that change nothing and record no undo step, a move with the exact shifted extent, DWG and DXF reopen with the payload lifting and the same extent, a reopened region moving again, and undo restoring the region exactly. Not covered: regions with holes and merging open curves into a boundary (the REGION command's second path), and AutoCAD acceptance of the payload. | | Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. | -| Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: booleans (about 23 s each in a debug build; release timing needed first), solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. | +| Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. **Booleans** (`SolidOperation::Boolean`, `doc.solids.union`, `subtract` and `intersect`) are covered by `audit_python_boolean_lifecycle_over_real_ipc`: two overlapping boxes give exact volumes (union 360, subtract and intersect 120) and extents, the operands are consumed unless `keep_operands` (undo restores both exactly), a result takes the first operand's layer or an explicit one, a second planar operation chains on the result (a through-hole gives 104), eight refusals change nothing and record no undo step (same operand twice, a non-solid, a missing entity, an unliftable payload, an empty layer, an unknown operation, a disjoint intersection), and DWG and DXF reopen losslessly with a reopened result moving again. The kernel refuses many curved cases (measured in release: about 1 to 2 s when it succeeds, and `Coincident`, `CutRefused` or `NoClosedForm` when it does not, including a second cut in a drilled box); its reason reaches the script and nothing changes, shown by an `#[ignore]` curved test that takes about 27 s in a debug build. Booleans block the host thread and count against the script's 30 s budget; only Solid3D operands are accepted. | | Surface | **Complete (plane surface, extruded open profile, isolines, rigid move)** | New `SolidOperation::SurfaceFromProfile` and `SolidOperation::Extrude`, exposed as `doc.solids.surface(profile)` and `doc.solids.extrude(profile, direction)`. A closed planar profile becomes a `Plane` surface (built as the region body, so it keeps the plane kind); a profile extruded along a nonzero direction becomes a Solid3D when it is closed and a generic Surface when it is open, using OCS's own `extrusion_body`. Every payload is verified lossless by `solid_to_sat`, and rigid moves (now accepted for surfaces too) keep a plane a plane and turn any other kind generic, as OCS does after an edit. Python sees `surface_kind`, `modeler_format_version`, `point_of_reference` and `history_handle` read-only and `u_isolines` and `v_isolines` writable (0 to 200); the payload, wires, silhouettes and the kind-specific `surface_data` are excluded and preserved. The kind field is exposed as `surface_kind` because a bare `kind` key names the entity type in every Python dict; the new `exposed_property_names_are_unique_per_kind` test guards against that class of collision. `audit_python_surface_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: two plane surfaces (one on its own layer), a surface from an open polyline extrusion, three extruded solids checked against their analytic volumes (a box exactly, curved ones within 2 percent), `delete_source` erasing only the intended profile, canvas geometry, nine refusals that change nothing and record no undo step, an isoline transaction with four rejections (500, negative, the read-only kind, an excluded key), moves with exact extents, DWG and DXF reopen with lifting payloads, matching extents, kept isolines and the plane kind, a reopened surface moving again, and exact undo. Not covered: lofted, revolved, swept and NURBS surfaces (a script cannot create them; an existing one is preserved and turns generic on a move), parametric history, and AutoCAD acceptance. | | Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. | diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 0d0643e53..c50d58b41 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -146,6 +146,26 @@ class _Solids: handle = profile.handle if isinstance(profile, _Entity) else int(profile) return self._document.entities[ocs.solid_region(handle, layer, bool(delete_source))] + def _boolean(self, operation, first, second, layer, keep_operands): + a = first.handle if isinstance(first, _Entity) else int(first) + b = second.handle if isinstance(second, _Entity) else int(second) + return self._document.entities[ocs.solid_boolean(a, b, operation, layer, bool(keep_operands))] + + def union(self, first, second, layer=None, keep_operands=False): + """Join two solids into a new one. The operands are consumed unless + `keep_operands`. The kernel may refuse (the error says why) and a + refusal changes nothing. Curved operands can take a second or two, + and a whole script has a 30 second budget.""" + return self._boolean("union", first, second, layer, keep_operands) + + def subtract(self, first, second, layer=None, keep_operands=False): + """Remove `second` from `first`, giving a new solid.""" + return self._boolean("subtract", first, second, layer, keep_operands) + + def intersect(self, first, second, layer=None, keep_operands=False): + """Keep only the volume both solids share, as a new solid.""" + return self._boolean("intersect", first, second, layer, keep_operands) + def surface(self, profile, layer=None, delete_source=False): """Make a plane surface from one closed planar profile.""" handle = profile.handle if isinstance(profile, _Entity) else int(profile) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 471761b45..2ff6cfc59 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -475,6 +475,40 @@ mod ocs { .map_err(|error| vm.new_runtime_error(format!("ocs.solid_region: {error}"))) } + /// Combine two solids ("union", "subtract" or "intersect"). + #[pyfunction] + fn solid_boolean( + first: u64, + second: u64, + operation: String, + layer: Option, + keep_operands: bool, + vm: &VirtualMachine, + ) -> PyResult { + use ocs_plugin_api::host::{SolidBoolean, SolidOperation}; + let operation = match operation.as_str() { + "union" => SolidBoolean::Union, + "subtract" => SolidBoolean::Subtract, + "intersect" => SolidBoolean::Intersect, + other => { + return Err(vm.new_value_error(format!("ocs.solid_boolean: unknown operation {other:?}"))) + } + }; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::Boolean { + first: Handle::new(first), + second: Handle::new(second), + operation, + layer, + keep_operands, + }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.solid_boolean: {error}"))) + } + /// Build a plane surface from a closed planar profile entity. #[pyfunction] fn solid_surface(source: u64, layer: Option, delete_source: bool, vm: &VirtualMachine) -> PyResult { diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 899c0cc7d..80c76ace3 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -692,6 +692,71 @@ impl<'a> HostSession<'a> { }; self.commit_profile_result(result, source, delete_source, "Extrude profile") } + SolidOperation::Boolean { first, second, operation, layer, keep_operands } => { + use ocs_plugin_api::host::SolidBoolean; + if first == second { + return Err("a solid cannot be combined with itself".into()); + } + if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { + return Err("layer name is empty".into()); + } + let operand = |host: &Self, handle: Handle, which: &str| -> Result { + match host.document().get_entity(handle) { + Some(EntityType::Solid3D(solid)) => Ok(solid.clone()), + Some(_) => Err(format!("the {which} operand must be a Solid3D")), + None => Err(format!("entity {handle:?} does not exist")), + } + }; + let a = operand(self, first, "first")?; + let b = operand(self, second, "second")?; + if !keep_operands { + for handle in [first, second] { + if self.app.tabs[self.tab].scene.is_layer_locked(handle) { + return Err(format!("entity {handle:?} is on a locked layer")); + } + } + } + let lift = |solid: &acadrust::entities::Solid3D, which: &str| { + crate::scene::convert::solid3d_tess::kernel_acis_body(&solid.acis_data).ok_or_else(|| { + format!("the {which} solid's payload cannot be lifted losslessly, so nothing was changed") + }) + }; + let (body_a, body_b) = (lift(&a, "first")?, lift(&b, "second")?); + let kind = match operation { + SolidBoolean::Union => model::Bool::Union, + SolidBoolean::Subtract => model::Bool::Subtract, + SolidBoolean::Intersect => model::Bool::Intersect, + }; + let combined = model::boolean_result(kind, &body_a, &body_b).map_err(|snag| { + let reason = match snag { + cadkernel::brep::Snag::Coincident => "two faces lie on the same surface, which the kernel cannot resolve", + cadkernel::brep::Snag::CutRefused => "a face could not be cut along the intersection curve", + cadkernel::brep::Snag::NoClosedForm => "the surfaces meet along a curve the kernel has no closed form for", + }; + format!("the geometry kernel refused this {operation:?} ({snag:?}): {reason}; nothing was changed") + })?; + let sat = crate::scene::convert::acis_export::solid_to_sat(&combined) + .ok_or("the result could not be exported losslessly, so nothing was changed")?; + let mut solid = acadrust::entities::Solid3D::new(); + solid.common = a.common.clone(); + solid.common.handle = Handle::NULL; + solid.common.owner_handle = Handle::NULL; + solid.wires = model::edge_wires(&combined); + solid.set_sat_document(&sat); + if let Some(layer) = layer { + solid.common.layer = layer; + } + self.push_undo("Boolean solids"); + let handle = self.add_entity(EntityType::Solid3D(solid)); + if handle.is_null() { + return Err("the result could not be added".into()); + } + if !keep_operands { + self.app.tabs[self.tab].scene.erase_entities(&[first, second]); + self.publish_document_view(); + } + Ok(handle) + } SolidOperation::Transform { handle, matrix } => { if matrix.iter().any(|v| !v.is_finite()) { return Err("transform matrix must be finite".into()); @@ -5327,6 +5392,175 @@ mod tests { assert_eq!(app.tabs[0].scene.document.get_entity(plane), Some(&plane_before), "undoing the edits restores the surface exactly"); } + #[test] + fn audit_python_boolean_lifecycle_over_real_ipc() { + use crate::scene::convert::solid3d_tess::kernel_body; + use crate::scene::model::solid_model as sm; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let solids_in = |document: &CadDocument| { + let mut list: Vec = document.entities().filter_map(|e| match e { + EntityType::Solid3D(s) => Some(s.common.handle), _ => None }).collect(); + list.sort_by_key(|h| h.value()); + list + }; + let body_in = |document: &CadDocument, handle: Handle| { + let Some(EntityType::Solid3D(solid)) = document.get_entity(handle) else { panic!("no solid {handle:?}") }; + kernel_body(solid).expect("payload lifts losslessly") + }; + let volume_in = |document: &CadDocument, handle: Handle| sm::volume(&body_in(document, handle)); + let extent_in = |document: &CadDocument, handle: Handle| sm::extent(&body_in(document, handle)).unwrap(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6 * b.abs().max(1.0); + + // C: all three operations on two overlapping boxes, keeping the + // operands. A: x 0..10, B: x 5..15, both 6 by 4; the overlap is 120. + let script = std::env::temp_dir().join(format!("ocs_boolean_create_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "s = ocs.active_document.solids\n", + "a = s.box(center=(5, 3, 2), size=(10, 6, 4))\n", + "b = s.box(center=(10, 3, 2), size=(10, 6, 4))\n", + "s.union(a, b, keep_operands=True)\n", + "s.subtract(a, b, keep_operands=True)\n", + "s.intersect(a, b, keep_operands=True)\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let list = solids_in(host.document()); + assert_eq!(list.len(), 5, "operands kept and three results: {}", last(&host)); + let (a, b) = (list[0], list[1]); + for (index, expected) in [(2, 360.0), (3, 120.0), (4, 120.0)] { + let actual = volume_in(host.document(), list[index]); + assert!(close(actual, expected), "result {index}: volume {actual}, expected {expected}"); + } + let (low, high) = extent_in(host.document(), list[3]); + assert!(close(low[0], 0.0) && close(high[0], 5.0), "subtract keeps x 0..5: {low:?} {high:?}"); + let (low, high) = extent_in(host.document(), list[4]); + assert!(close(low[0], 5.0) && close(high[0], 10.0), "intersect keeps x 5..10: {low:?} {high:?}"); + let Some(EntityType::Solid3D(union)) = host.document().get_entity(list[2]) else { unreachable!() }; + assert_eq!((union.wires.is_empty(), union.history_handle), (false, None)); + assert!(!host.app.tabs[0].scene.wire_models_for(&[list[2]]).is_empty(), "no canvas geometry"); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.entities[{}].kind", list[2].value())); + assert!(last(&host).contains("Solid3D")); + + // Consuming operands: A minus B leaves only the result, on a new layer. + let before_consume = (host.document().get_entity(a).unwrap().clone(), host.document().get_entity(b).unwrap().clone()); + let undo_before = host.app.tabs[0].history.undo_stack.len(); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.subtract({}, {}, layer='CUT').handle", a.value(), b.value())); + assert!(host.document().get_entity(a).is_none() && host.document().get_entity(b).is_none(), "operands are consumed"); + let cut = *solids_in(host.document()).last().unwrap(); + assert!(close(volume_in(host.document(), cut), 120.0)); + assert_eq!(host.document().get_entity(cut).unwrap().common().layer, "CUT"); + assert_eq!(host.app.tabs[0].history.undo_stack.len() - undo_before, 1, "one undo step per boolean"); + + // A second planar operation chains on the result: drill a through-hole. + dispatch(&mut host, "PY_EVAL ocs.active_document.solids.box(center=(2.5, 3, 2), size=(2, 2, 6)).handle"); + let hole = *solids_in(host.document()).last().unwrap(); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.subtract({}, {}).handle", cut.value(), hole.value())); + let drilled = *solids_in(host.document()).last().unwrap(); + assert!(close(volume_in(host.document(), drilled), 104.0), "120 minus a 2x2x4 hole is 104"); + + // V: refusals change nothing and leave no undo step. + let line = host.add_entity(EntityType::Line(acadrust::entities::Line::from_points( + acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(1.0, 0.0, 0.0)))); + let unliftable = host.add_entity(EntityType::Solid3D(acadrust::entities::Solid3D::new())); + let mut plane = acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane); + plane.acis_data = acadrust::entities::AcisData::new(); + let surface = host.add_entity(EntityType::Surface(plane)); + let far = { dispatch(&mut host, "PY_EVAL ocs.active_document.solids.box(center=(500, 500, 500), size=(1, 1, 1)).handle"); + *solids_in(host.document()).last().unwrap() }; + let counts = (host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()); + let z = drilled.value(); + for (command, message) in [ + (format!("ocs.active_document.solids.union({z}, {z})"), "cannot be combined with itself"), + (format!("ocs.active_document.solids.union({z}, {})", line.value()), "must be a Solid3D"), + (format!("ocs.active_document.solids.union({}, {z})", surface.value()), "must be a Solid3D"), + (format!("ocs.active_document.solids.union({z}, 999999)"), "does not exist"), + (format!("ocs.active_document.solids.union({z}, {})", unliftable.value()), "cannot be lifted losslessly"), + (format!("ocs.active_document.solids.union({z}, {}, layer='')", far.value()), "layer name is empty"), + (format!("ocs.solid_boolean({z}, {}, 'xor', None, False)", far.value()), "unknown operation"), + (format!("ocs.active_document.solids.intersect({z}, {})", far.value()), "changed"), + ] { + dispatch(&mut host, &format!("PY_EVAL {command}")); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!((host.document().entities().count(), host.app.tabs[0].history.undo_stack.len()), counts, + "refusals change nothing and record no undo step"); + + // W: both formats reopen the drilled result losslessly; it moves again. + let expected_volume = volume_in(host.document(), drilled); + let expected_extent = extent_in(host.document(), drilled); + let dwg = crate::io::load_bytes("boolean.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("boolean.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + for (format, document) in [("DWG", &dwg), ("DXF", &dxf)] { + assert!(close(volume_in(document, drilled), expected_volume), "{format} volume"); + let reopened = extent_in(document, drilled); + for axis in 0..3 { + assert!(close(reopened.0[axis], expected_extent.0[axis]) && close(reopened.1[axis], expected_extent.1[axis]), "{format} extent"); + } + let Some(EntityType::Solid3D(back)) = document.get_entity(drilled) else { unreachable!() }; + let again = host.add_entity(EntityType::Solid3D(back.clone())); + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.solids.translate({}, (100, 0, 0)).handle", again.value())); + assert!(close(extent_in(host.document(), again).0[0], reopened.0[0] + 100.0), "{format} re-edit"); + } + + // D and U: delete, then undo restores it, and undoing the consuming + // boolean brings both operands back exactly. + dispatch(&mut host, &format!("PY_EVAL ocs.active_document.delete_entity({})", drilled.value())); + assert!(host.document().get_entity(drilled).is_none()); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(1); + assert!(app.tabs[0].scene.document.get_entity(drilled).is_some(), "undo restores the deleted result"); + // Two re-edits, the far box, the drilling subtract and the hole box, then the consuming subtract. + app.undo_steps(2 + 1 + 1 + 1 + 1); + assert_eq!(app.tabs[0].scene.document.get_entity(a), Some(&before_consume.0), "undo restores operand A exactly"); + assert_eq!(app.tabs[0].scene.document.get_entity(b), Some(&before_consume.1), "undo restores operand B exactly"); + assert!(app.tabs[0].scene.document.get_entity(cut).is_none(), "undo removes the boolean result"); + } + + /// Release-only evidence for docs/cadkernel-body-path.md: the kernel's + /// refusals reach the script as messages. Curved booleans take about + /// 23 s in a debug build, so this is ignored by default. + #[test] + #[ignore = "curved boolean; slow in debug builds"] + fn audit_python_boolean_kernel_refusal_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let script = std::env::temp_dir().join(format!("ocs_boolean_refusal_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "s = ocs.active_document.solids\n", + "box = s.box(center=(0, 0, 0), size=(10, 6, 4))\n", + "sphere = s.sphere(center=(0, 0, 0), radius=3)\n", + "s.subtract(box, sphere)\n", + )).unwrap(); + dispatch(&mut host, &format!("PY_RUN {}", script.display())); + let _ = std::fs::remove_file(&script); + let message = host.app.command_line.history.last().unwrap().text.clone(); + assert!(message.contains("Coincident") && message.contains("nothing was changed"), "{message}"); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 942222c3811107d9c03fc2fb54fcf243919c4a7d Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 18:05:38 +0200 Subject: [PATCH 46/76] Map SectionSymbol, the last unmapped kind points is canonical; the host derives the point counts and refreshes the end/tick/label projections with sync_display_fields when it changes, and validates that style and view-representation references are existing class objects. The lifecycle test passes every gate in DWG; DXF reopens the symbol as an unknown entity because cadcodec dispatches SECTIONLINE only inside blocks (pinned by a canary and already in the cadcodec write-up). Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/build.rs | 1 + .../entity_coverage_policy.json | 20 ++++ crates/ocs_plugin_api/src/entity_coverage.rs | 56 +++++++++ docs/cadcodec-reader-gaps.md | 3 +- docs/plugin-host-model-completion-plan.md | 5 +- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 4 +- plugins/opencad-python/README.md | 2 +- plugins/opencad-python/entity_manifest.json | 44 ++++++- plugins/opencad-python/src/ocs_module.rs | 2 +- src/app/plugin_host.rs | 111 ++++++++++++++++++ 11 files changed, 242 insertions(+), 10 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 8da98a6f8..87387e1c5 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -793,6 +793,7 @@ fn generate_entity_coverage(out_dir: &Path, registry: &TypeRegistry) { | ("Helix", _) | ("RasterImage", _) | ("Wipeout", _) + | ("SectionSymbol", _) | ("Surface", "u_isolines" | "v_isolines") | ("Ole2Frame", _) | ("Underlay", _) diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 2d7bfc98b..435d37089 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -507,6 +507,13 @@ "layer", "u_isolines", "v_isolines" + ], + "SectionSymbol": [ + "layer", + "symbol_scale", + "points", + "style_handle", + "view_rep_handle" ] }, "readable": { @@ -638,6 +645,19 @@ "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": { diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index e36ded41d..d09bca4cb 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -1263,6 +1263,33 @@ fn validate_surface( Ok(()) } +/// SectionSymbol: the cut mark drawn on a Model-Documentation view. `points` +/// is the canonical geometry; the counts and the end/tick/label projections are +/// derived by the host, so only the inputs are validated. +#[cfg(feature = "host")] +fn validate_section_symbol( + old: Option<&crate::host::acadrust::entities::SectionSymbol>, + new: &crate::host::acadrust::entities::SectionSymbol, +) -> Result<(), String> { + if old.is_some_and(|old| old.points == new.points && old.symbol_scale.to_bits() == new.symbol_scale.to_bits()) { + return Ok(()); + } + if !new.symbol_scale.is_finite() || new.symbol_scale <= 0.0 { + return Err("SectionSymbol.symbol_scale must be finite and greater than zero".into()); + } + if new.points.len() < 2 { + return Err("SectionSymbol.points requires at least 2 points".into()); + } + for (index, point) in new.points.iter().enumerate() { + finite_vector(&format!("SectionSymbol.points[{index}].point"), &point.point)?; + finite_vector(&format!("SectionSymbol.points[{index}].label_offset"), &point.label_offset)?; + if !point.bulge.is_finite() { + return Err(format!("SectionSymbol.points[{index}].bulge must be finite")); + } + } + Ok(()) +} + /// Validate newly created geometry for the kinds whose mapped fields have /// host checks. Called by the Python add path before an entity enters the document. #[cfg(feature = "host")] @@ -1390,6 +1417,7 @@ pub fn validate_new_canvas_entity(entity: &crate::host::EntityType) -> Result<() } EntityType::Ole2Frame(value) => validate_ole_frame(None, value)?, EntityType::Surface(value) => validate_surface(None, value)?, + EntityType::SectionSymbol(value) => validate_section_symbol(None, value)?, EntityType::AttributeDefinition(value) => { finite_vector( "AttributeDefinition.insertion_point", @@ -1765,6 +1793,7 @@ pub fn validate_entity_mutation( } } (EntityType::Surface(old), EntityType::Surface(new)) => validate_surface(Some(old), new)?, + (EntityType::SectionSymbol(old), EntityType::SectionSymbol(new)) => validate_section_symbol(Some(old), new)?, (EntityType::MLine(old), EntityType::MLine(new)) => validate_mline(Some(old), new)?, (EntityType::Leader(old), EntityType::Leader(new)) => validate_leader(Some(old), new)?, (EntityType::AttributeDefinition(old), EntityType::AttributeDefinition(new)) => { @@ -2075,6 +2104,28 @@ pub fn validate_canvas_entity_references( } } } + if let EntityType::SectionSymbol(value) = entity { + use crate::host::acadrust::objects::{ClassObjectData, ObjectType}; + for (name, handle, wanted) in [ + ("style", value.style_handle, "SectionViewStyle"), + ("view representation", value.view_rep_handle, "ViewRep"), + ] { + if handle.is_null() { + continue; + } + let matches = match document.objects.get(&handle) { + Some(ObjectType::ClassObject(object)) => match (&object.data, wanted) { + (ClassObjectData::SectionViewStyle(_), "SectionViewStyle") => true, + (ClassObjectData::ViewRep(_), "ViewRep") => true, + _ => false, + }, + _ => false, + }; + if !matches { + return Err(format!("SectionSymbol {name} {handle:?} is not an existing {wanted} object")); + } + } + } if let EntityType::ViewBorder(value) = entity { if !matches!(document.get_entity(value.active_viewport), Some(EntityType::Viewport(_))) { return Err(format!("ViewBorder viewport {:?} does not exist", value.active_viewport)); @@ -2570,6 +2621,7 @@ mod tests { | "Body" | "Region" | "Surface" + | "SectionSymbol" ) { assert!( @@ -2861,6 +2913,10 @@ mod tests { "Ole2Frame.is_paper_space".to_owned(), "Surface.u_isolines".to_owned(), "Surface.v_isolines".to_owned(), + "SectionSymbol.symbol_scale".to_owned(), + "SectionSymbol.points".to_owned(), + "SectionSymbol.style_handle".to_owned(), + "SectionSymbol.view_rep_handle".to_owned(), "Underlay.underlay_type".to_owned(), "Underlay.definition_handle".to_owned(), "Underlay.insertion_point".to_owned(), diff --git a/docs/cadcodec-reader-gaps.md b/docs/cadcodec-reader-gaps.md index e8f5cc277..9dbe422c4 100644 --- a/docs/cadcodec-reader-gaps.md +++ b/docs/cadcodec-reader-gaps.md @@ -151,7 +151,8 @@ reopens as a different, opaque entity kind instead of `ViewBorder`. DWG restores the typed record. The same applies to `SectionSymbol`. **Evidence:** `audit_python_view_border_lifecycle_over_real_ipc` (canary -`expect_edited_dxf: "wrong kind"`). +`expect_edited_dxf: "wrong kind"`) and `audit_python_section_symbol_lifecycle_over_real_ipc` +(canary `wrong kind Unknown`). **Suggested fix:** add the two arms to the ENTITIES dispatch, calling `read_view_border_dxf` and `read_section_symbol_dxf`. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 6008240c6..4ea89d902 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,13 +7,13 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **42 of 43** canvas kinds: 39 +v7. The Python feature has explicit writes for **43 of 43** canvas kinds: 40 are creatable (the legacy `Polyline`, `Ole2Frame` and `Body` are update-only). This is **not** 41 completed kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D, Region and Surface pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder and Light are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. The remaining canvas kind, SectionSymbol, has only `layer` and is recorded as blocked +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder, Light and SectionSymbol are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. Every canvas kind now has explicit writes; SectionSymbol is mapped and integration-tested but blocked at DXF persistence by cadcodec writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -182,3 +182,4 @@ exact queue above and update statuses with evidence after each increment. - Completed `Surface` for planes, extruded open profiles, isolines and rigid moves. `SurfaceFromProfile` and `Extrude` join the kernel operations (`doc.solids.surface` and `doc.solids.extrude`); `Extrude` also gives Solid3D extrusions from closed profiles, checked against analytic volumes. The test found and fixed a real bug: Surface's `kind` field silently overwrote the entity-type `kind` key in Python dicts, so it is exposed as `surface_kind`, and a new uniqueness test guards the whole catalog. Transform now accepts surfaces (a plane stays a plane; other kinds turn generic). Left: booleans (release timing first), regions with holes, and lofted, revolved, swept and NURBS surfaces. SectionSymbol is the only unmapped kind. - Timed booleans in a release build (details in [cadkernel-body-path.md](cadkernel-body-path.md)). A curved box-and-cylinder boolean takes about 1 to 2.2 s, overlapping spheres 0.2 s and planar operands 1 to 2 ms, against 23 s in a debug build. The kernel refuses many curved cases quickly with a reason (`Coincident`, `CutRefused`, `NoClosedForm`), including a second cut in an already drilled box. Recommendation: add a synchronous `SolidOperation::Boolean` that surfaces the refusal and leaves the operands untouched, test it with planar operands plus one ignored curved case, and note the 30 s per-script budget. No code changed in this step. - Implemented `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect`, as recommended by the timing study. It runs synchronously, surfaces the kernel's refusal (`Coincident`, `CutRefused`, `NoClosedForm`) with a plain-language reason and the words "nothing was changed", consumes the operands unless `keep_operands`, and records one undo step. The lifecycle test uses planar boxes (millisecond cost, exact volumes, debug-safe) and an `#[ignore]` test documents a curved refusal. With this the kernel operations in the cadkernel note are all built. SectionSymbol is the only unmapped kind. +- Mapped `SectionSymbol`, the last unmapped kind, so all 43 canvas kinds now have explicit writes. The blocker recorded earlier was overstated: cadcodec documents `points` as canonical, says the counts equal the point count in every verified export, and provides `sync_display_fields()`, which OCS's own edits already call; OCS also renders a symbol with no style or view link. The host derives the counts and projections when `points` changes and validates that references are existing `SectionViewStyle` and `ViewRep` class objects. Every gate passes except DXF, where cadcodec reopens a top-level `SECTIONLINE` as an unknown entity (issue 5 in the cadcodec write-up, pinned by a canary). Still unverifiable here: the meaning of `raw_flags_90` and AutoCAD's acceptance of a symbol with no parent view. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 3d3444c37..efa7ebdc1 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **42 of 43 canvas kinds have explicit geometry writes** (39 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **29 kinds are complete**, and 1 remains layer-only (SectionSymbol, blocked). `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **43 of 43 canvas kinds have explicit geometry writes** (40 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **29 kinds are complete**, and none remains layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -42,7 +42,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Underlay | **Integration-tested / blocked at W by acadrust (DXF rotation units)** | Writable: `underlay_type` and `definition_handle` (both fixed by the definition: creation names an existing `UnderlayDefinition`, and a mismatched type or missing definition is rejected), `insertion_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `flags`, `contrast`, `fade`, `clip_boundary_vertices`, `clip_inverted`. The file path and page live on the definition object, which scripts cannot create or list yet; they reference one the drawing already owns. Host validation requires finite nonzero scales, a finite rotation, a nonzero normal, contrast/fade within 0..=100, known flags, finite clip vertices (none, or at least 2) and an existing definition of the same type. `staged_python_underlay_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle` with a fixture PDF definition) passes C/R/E/D/U/I/V/P and DWG W: creation, a placement/scale/rotation/contrast/fade/clip transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by canary assertions):** DXF stores the rotation in degrees but the reader returns it unconverted, so 0.5 rad reopens as 28.6 and each further DXF save-reopen compounds it (1641.4 after a second), silently corrupting the rotation. Not `Complete` until the reader converts degrees back to radians. Not covered: creating definitions from a file path, DWF/DGN fixtures. | | Viewport | **Integration-tested** | Writable: `center`, `width`, `height`, `status`, `view_center`, `view_direction`, `view_target`, `lens_length`, `front_clip_z`, `back_clip_z`, `view_height`, `twist_angle`, `custom_scale`, `frozen_layers` (layer handles), `render_mode`, `circle_sides`, `ucs_icon_visible`. Read-only: `id`, snap/grid settings, UCS fields, `clip_boundary_handle`, `style_sheet`, shade-plot, background, visual-style and sun handles, lighting, `ambient_color`, `elevation`. Reference rules: the owner must be a paper-space layout block (a model-space viewport is rejected) and every frozen-layer handle must be an existing layer. Host validation requires finite centers, positive width, height, view height, lens length and scale, a nonzero view direction, finite clip planes and twist, and at least 3 circle sides. `staged_python_viewport_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, paper-space owner supplied by the driver) passes C/R/E/D/U/V/W/P: creation in the paper-space block, a move/resize/view-height/twist/frozen-layer transaction, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. **Why not `Complete`:** the I gate lacks a canvas oracle because a paper-space viewport only draws on its layout tab, which the driver does not activate; viewport `id` uniqueness within a layout, clip boundary entities and view-to-scale synchronization are not handled by the host. | | ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | -| SectionSymbol | **Blocked** | Not mapped. A valid section mark needs a `SectionViewStyle` and a `ViewRep` view-representation object that a script cannot create through the host API, and the entity stores raw point counts and flag words (`raw_point_count_90`, `raw_flags_90`, `raw_point_record_count`, `raw_view_symbol_70`) whose relationship to `points` is undocumented in acadrust and is written back verbatim by both writers, so the host cannot keep them consistent after a scripted edit without guessing. Adding the kind needs (1) host-side creation or lookup of a section style and view representation, and (2) a confirmed derivation of the raw counts from a real DWG produced by another CAD application. The kind keeps raw snapshot access and layer edits. | +| SectionSymbol | **Integration-tested / blocked at W by cadcodec (DXF SECTIONLINE)** | The earlier blocker was overstated, and reading cadcodec and OCS showed why. cadcodec documents `points` as the canonical geometry, says the point-count fields equal the point count in every verified ODA export, and ships `sync_display_fields()` to refresh the `end_a`/`end_b`/`tick_a`/`tick_b`/`label` projections; OCS's own grip and transform code already calls it. OCS also renders a symbol with no style and no view link. So the kind is mapped: writable `points` (each `{point, bulge, label, label_offset, raw_flag_280}`), `symbol_scale`, `style_handle` and `view_rep_handle`; read-only `view_symbol_version`, `raw_view_symbol_70`, `version`, `raw_flags_90`, the two derived counts and the five projections. The host sets `raw_point_count_90` and `raw_point_record_count` to the point count and calls `sync_display_fields()` whenever `points` is created or changed, and leaves a symbol whose points did not change exactly as read. Host validation requires at least two finite points with finite bulges and label offsets, a positive scale, and that each non-null reference is an existing `SectionViewStyle` (style) or `ViewRep` (view representation) class object; the wrong object kind is refused. Creating one without references gives a standalone symbol. `audit_python_section_symbol_lifecycle_over_real_ipc` (shared driver) passes C/R/E/D/U/I/V/P and DWG W: a two-point symbol, a three-point edit with a new label, bulge and scale in one transaction (counts and projections follow), canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, three-step undo/redo. `audit_python_section_symbol_references_over_real_ipc` covers valid references, a standalone symbol and four wrong-object refusals with host-made class objects. **W blocker (cadcodec, pinned by a canary; issue 5 in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md)):** the DXF reader dispatches `SECTIONLINE` only inside blocks, so a symbol in the drawing's entity list reopens as an `Unknown` entity. Open items that cannot be checked here: `raw_flags_90` has unknown semantics (0 for a new symbol, preserved on read), and whether AutoCAD accepts a symbol with no parent view. Scripts cannot create the style or view-representation objects. | | Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. | | Region | **Complete (create from a profile + rigid move)** | New `SolidOperation::RegionFromProfile` and `doc.solids.region(profile, layer=None, delete_source=False)`. The host builds the region exactly as the REGION command does (`presspull_model::profile_geometry` then `cadkernel::brep::planar_region`), verifies it with `solid_to_sat`, puts it on `layer` or on the profile's layer, and erases the profile only when `delete_source` is set. One closed planar profile per call (a circle, ellipse, closed polyline, spline and so on); an open profile, a ray, a region, a missing entity or an empty layer is refused with a message before anything changes. Rigid moves go through the same `doc.solids.transform` channel as Solid3D. `audit_python_region_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: regions from a circle (source kept), a closed rectangle (source deleted) and a second circle on its own layer with exact extents, canvas geometry, six refusals that change nothing and record no undo step, a move with the exact shifted extent, DWG and DXF reopen with the payload lifting and the same extent, a reopened region moving again, and undo restoring the region exactly. Not covered: regions with holes and merging open curves into a boundary (the REGION command's second path), and AutoCAD acceptance of the payload. | | Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. | diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 8bb6e0ca6..39a91be8c 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -35,7 +35,7 @@ attached attribute and sequence records. Those fields report `transaction_geomet catalog. All 43 canvas kinds also support nonempty `layer` changes through the same undoable transaction; internal and opaque variants remain read-only through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 42 geometry converters. Other writable properties +canvas kinds outside its 43 geometry converters. Other writable properties still report `type_conversion_only`; broader CAD range and cross-property checks remain outstanding. The host's `entity_snapshot` helper serializes any typed entity as a detached @@ -92,7 +92,7 @@ Input tokens are bound to the drawing tab that requested the pick. Polling a token from another tab returns no result and does not consume it. Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 42-kind generated schema it contains only handle, kind, and layer; +outside the 43-kind generated schema it contains only handle, kind, and layer; `layer` is editable on canvas kinds. `doc.coverage()` enumerates the catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For covered kinds, the coverage object lists actual readable and editable diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 7f594836e..94a7796f5 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -94,7 +94,7 @@ The host-owned catalog classifies all 48 entity variants and tracks every traced field, including fields marked `unmapped`. Query it with `doc.coverage()` or `doc.coverage("Line")`; each row includes source field, type, access, and current validation status. Geometry edits remain limited to -the 42 mapped kinds. +the 43 mapped kinds. For any entity, `entity.snapshot` returns a detached dictionary of its raw CAD fields, including `snapshot["common"]` and kind-specific nested values. It can inspect an unmapped kind; editing the dictionary does diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 014a750b6..05d48d57b 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -41,7 +41,8 @@ "Solid3D", "Body", "Region", - "Surface" + "Surface", + "SectionSymbol" ], "manual_kinds": [ "Dimension" @@ -926,6 +927,47 @@ "read_only": true } } + }, + "SectionSymbol": { + "fields": { + "points": { + "required": true + }, + "view_symbol_version": { + "read_only": true + }, + "raw_view_symbol_70": { + "read_only": true + }, + "version": { + "read_only": true + }, + "raw_point_count_90": { + "read_only": true + }, + "raw_flags_90": { + "read_only": true, + "_comment": "Semantics unknown in cadcodec; preserved as read, 0 for a new symbol." + }, + "raw_point_record_count": { + "read_only": true + }, + "end_a": { + "read_only": true + }, + "end_b": { + "read_only": true + }, + "tick_a": { + "read_only": true + }, + "tick_b": { + "read_only": true + }, + "label": { + "read_only": true + } + } } } } diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 2ff6cfc59..2f860727d 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4983,7 +4983,7 @@ mod tests { ); } } - assert_eq!(emitted.len(), 42); + assert_eq!(emitted.len(), 43); } #[cfg(feature = "experimental-host-model")] diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 80c76ace3..dd3d531db 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -158,6 +158,19 @@ impl<'a> HostSession<'a> { if let EntityType::Dimension(dimension) = entity { crate::entities::dimension::normalize_scripted_dimension(dimension); } + if let EntityType::SectionSymbol(symbol) = entity { + // `points` is the canonical geometry: the point counts follow it + // (they equal the point count in every verified export) and the + // end, tick and label projections are refreshed from it. A symbol + // whose points did not change is left exactly as it was read. + let unchanged = matches!(old, Some(EntityType::SectionSymbol(previous)) if previous.points == symbol.points); + if !unchanged { + let count = i32::try_from(symbol.points.len()).unwrap_or(i32::MAX); + symbol.raw_point_count_90 = count; + symbol.raw_point_record_count = count; + symbol.sync_display_fields(); + } + } if let EntityType::Spline(spline) = entity { // The DXF writer emits weights only for a rational spline, so the // flag must follow the weights a script sets. @@ -5561,6 +5574,104 @@ mod tests { assert!(message.contains("Coincident") && message.contains("nothing was changed"), "{message}"); } + #[test] + fn audit_python_section_symbol_lifecycle_over_real_ipc() { + run_mesh_lifecycle(&MeshCase { + create: concat!( + "doc.create_entity('SectionSymbol', symbol_scale=1.0, points=[\n", + " {'point': P(0.0, 0.0, 0.0), 'label': 'A', 'label_offset': P(0.0, 2.0, 0.0)},\n", + " {'point': P(40.0, 0.0, 0.0), 'label': 'A', 'label_offset': P(0.0, 2.0, 0.0)}])\n"), + edit: concat!( + "e = doc.entities[HANDLE]\n", + "with doc.transaction('Edit'):\n", + " e.points = [{'point': P(0.0, 0.0, 0.0), 'label': 'B', 'label_offset': P(0.0, 3.0, 0.0)},\n", + " {'point': P(20.0, 10.0, 0.0), 'bulge': 0.0}, {'point': P(40.0, 0.0, 0.0), 'label': 'B', 'label_offset': P(0.0, -3.0, 0.0)}]\n", + " e.symbol_scale = 2.0\n", + "doc.selection = [e]\n"), + rejects: &[ + ("'points':[{'point':{'x':0.0,'y':0.0,'z':0.0}}]", "at least 2 points"), + ("'points':[{'point':{'x':float('nan'),'y':0.0,'z':0.0}},{'point':{'x':1.0,'y':0.0,'z':0.0}}]", "finite"), + ("'symbol_scale':0.0", "greater than zero"), + ("'style_handle':999999", "not an existing SectionViewStyle"), + ("'view_rep_handle':999999", "not an existing ViewRep"), + ("'raw_point_count_90':9", "read-only"), + ("'end_a':[1.0, 1.0]", "read-only"), + ], + is_kind: |entity| matches!(entity, EntityType::SectionSymbol(_)), + digest: |entity| match entity { + EntityType::SectionSymbol(v) => format!("n{} ends {:.1},{:.1}->{:.1},{:.1} label{} tick{:.1}/{:.1} scale{:.1} counts{}/{}", + v.points.len(), v.end_a[0], v.end_a[1], v.end_b[0], v.end_b[1], v.label, v.tick_a, v.tick_b, + v.symbol_scale, v.raw_point_count_90, v.raw_point_record_count), + other => format!("wrong kind {}", format!("{other:?}").split('(').next().unwrap()), + }, + reedit: |entity| if let EntityType::SectionSymbol(v) = entity { v.symbol_scale = 3.0; }, + expect_created: "n2 ends 0.0,0.0->40.0,0.0 labelA tick2.0/2.0 scale1.0 counts2/2", + expect_edited: "n3 ends 0.0,0.0->40.0,0.0 labelB tick3.0/-3.0 scale2.0 counts3/3", + expect_reedited: "n3 ends 0.0,0.0->40.0,0.0 labelB tick3.0/-3.0 scale3.0 counts3/3", + // BLOCKER (cadcodec, see docs/cadcodec-reader-gaps.md issue 5): the + // DXF reader dispatches SECTIONLINE only inside blocks, so a symbol + // in the entity list reopens as an unknown entity. + expect_edited_dxf: "wrong kind Unknown", + expect_reedited_dxf: "wrong kind Unknown", + expect_edited_dwg: "", + expect_reedited_dwg: "", + }); + } + + #[test] + fn audit_python_section_symbol_references_over_real_ipc() { + use acadrust::objects::{ClassObject, ClassObjectData, ObjectType}; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let mut object = |host: &mut HostSession<'_>, data: ClassObjectData| { + let handle = host.document_mut().allocate_handle(); + let mut class_object = ClassObject::new(data); + class_object.handle = handle; + host.document_mut().objects.insert(handle, ObjectType::ClassObject(class_object)); + handle + }; + let style = object(&mut host, ClassObjectData::SectionViewStyle(Default::default())); + let view_rep = object(&mut host, ClassObjectData::ViewRep(Default::default())); + let scale = host.document_mut().allocate_handle(); + let mut scale_object = acadrust::objects::Scale::new("1:1", 1.0, 1.0); + scale_object.handle = scale; + host.document_mut().objects.insert(scale, ObjectType::Scale(scale_object)); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let count = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::SectionSymbol(_))).count(); + let create = |extra: &str| format!( + "PY_EVAL ocs.active_document.create_entity('SectionSymbol', points=[{{'point': {{'x': 0.0, 'y': 0.0, 'z': 0.0}}}}, {{'point': {{'x': 10.0, 'y': 0.0, 'z': 0.0}}}}]{extra}).handle"); + // A symbol may reference the drawing's own style and view representation. + dispatch(&mut host, &create(&format!(", style_handle={}, view_rep_handle={}", style.value(), view_rep.value()))); + assert_eq!(count(&host), 1, "{}", last(&host)); + let Some(EntityType::SectionSymbol(symbol)) = host.document().entities().find(|e| matches!(e, EntityType::SectionSymbol(_))) else { unreachable!() }; + assert_eq!((symbol.style_handle, symbol.view_rep_handle), (style, view_rep)); + // A standalone symbol needs neither. + dispatch(&mut host, &create("")); + assert_eq!(count(&host), 2, "{}", last(&host)); + // Wrong object kinds are refused: a style slot cannot hold a view rep, a + // scale object is neither, and a swapped pair fails. + for (extra, message) in [ + (format!(", style_handle={}", view_rep.value()), "not an existing SectionViewStyle"), + (format!(", style_handle={}", scale.value()), "not an existing SectionViewStyle"), + (format!(", view_rep_handle={}", style.value()), "not an existing ViewRep"), + (", view_rep_handle=424242".to_string(), "not an existing ViewRep"), + ] { + dispatch(&mut host, &create(&extra)); + assert_eq!(count(&host), 2, "{extra}"); + assert!(last(&host).contains(message), "{extra}: {}", last(&host)); + } + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 5e6a8307829db17db9ae1d5081c842e48cfefad9 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 18:12:31 +0200 Subject: [PATCH 47/76] Complete Ole2Frame: embed a picture file from a script EmbedPicture builds the frame with OCS's own EmbeddedImage and build_embedded_ole, so the opaque storage is never scripted. PNG, JPEG and BMP are stored as read and other formats are re-encoded as PNG. The lifecycle test now creates through Python; a new test covers formats, aspect, origin, layer and refusals. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 5 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 1 + docs/plugin-host-model-completion-plan.md | 7 +- docs/plugin-host-model-coverage-ledger.md | 4 +- plugins/opencad-python/entity_manifest.json | 2 +- plugins/opencad-python/src/document_model.py | 7 ++ plugins/opencad-python/src/ocs_module.rs | 22 ++++ src/app/plugin_host.rs | 107 ++++++++++++++++++- 8 files changed, 148 insertions(+), 7 deletions(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 3a18d1fb0..f95c2880a 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -109,6 +109,11 @@ pub enum SolidOperation { /// 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 }, Boolean { first: Handle, second: Handle, diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 8662e4888..3e448ea89 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -115,6 +115,7 @@ mod tests { }, 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), diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 4ea89d902..d764823ee 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,9 +7,9 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **43 of 43** canvas kinds: 40 -are creatable (the legacy `Polyline`, `Ole2Frame` and `Body` are update-only). This is **not** 41 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D, Region and Surface pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. +v7. The Python feature has explicit writes for **43 of 43** canvas kinds: 41 +are creatable (the legacy `Polyline` and `Body` are update-only). This is **not** 41 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D, Region, Surface and Ole2Frame pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current @@ -183,3 +183,4 @@ exact queue above and update statuses with evidence after each increment. - Timed booleans in a release build (details in [cadkernel-body-path.md](cadkernel-body-path.md)). A curved box-and-cylinder boolean takes about 1 to 2.2 s, overlapping spheres 0.2 s and planar operands 1 to 2 ms, against 23 s in a debug build. The kernel refuses many curved cases quickly with a reason (`Coincident`, `CutRefused`, `NoClosedForm`), including a second cut in an already drilled box. Recommendation: add a synchronous `SolidOperation::Boolean` that surfaces the refusal and leaves the operands untouched, test it with planar operands plus one ignored curved case, and note the 30 s per-script budget. No code changed in this step. - Implemented `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect`, as recommended by the timing study. It runs synchronously, surfaces the kernel's refusal (`Coincident`, `CutRefused`, `NoClosedForm`) with a plain-language reason and the words "nothing was changed", consumes the operands unless `keep_operands`, and records one undo step. The lifecycle test uses planar boxes (millisecond cost, exact volumes, debug-safe) and an `#[ignore]` test documents a curved refusal. With this the kernel operations in the cadkernel note are all built. SectionSymbol is the only unmapped kind. - Mapped `SectionSymbol`, the last unmapped kind, so all 43 canvas kinds now have explicit writes. The blocker recorded earlier was overstated: cadcodec documents `points` as canonical, says the counts equal the point count in every verified export, and provides `sync_display_fields()`, which OCS's own edits already call; OCS also renders a symbol with no style or view link. The host derives the counts and projections when `points` changes and validates that references are existing `SectionViewStyle` and `ViewRep` class objects. Every gate passes except DXF, where cadcodec reopens a top-level `SECTIONLINE` as an unknown entity (issue 5 in the cadcodec write-up, pinned by a canary). Still unverifiable here: the meaning of `raw_flags_90` and AutoCAD's acceptance of a symbol with no parent view. +- Completed `Ole2Frame` by adding creation. `SolidOperation::EmbedPicture` and `doc.embed_picture` build the frame from a picture file with OCS's own embedding code, so a script never touches the opaque storage; PNG, JPEG and BMP are stored byte-for-byte and other formats are re-encoded as PNG. The lifecycle test now creates through Python and the new creation-rules test covers formats, aspect, origin, layer and seven refusals. 30 kinds are complete. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index efa7ebdc1..d072b7119 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **43 of 43 canvas kinds have explicit geometry writes** (40 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **29 kinds are complete**, and none remains layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **43 of 43 canvas kinds have explicit geometry writes** (41 creatable, plus update-only legacy `Polyline` and `Body`), **30 kinds are complete**, and none remains layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -48,7 +48,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. | | Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. **Booleans** (`SolidOperation::Boolean`, `doc.solids.union`, `subtract` and `intersect`) are covered by `audit_python_boolean_lifecycle_over_real_ipc`: two overlapping boxes give exact volumes (union 360, subtract and intersect 120) and extents, the operands are consumed unless `keep_operands` (undo restores both exactly), a result takes the first operand's layer or an explicit one, a second planar operation chains on the result (a through-hole gives 104), eight refusals change nothing and record no undo step (same operand twice, a non-solid, a missing entity, an unliftable payload, an empty layer, an unknown operation, a disjoint intersection), and DWG and DXF reopen losslessly with a reopened result moving again. The kernel refuses many curved cases (measured in release: about 1 to 2 s when it succeeds, and `Coincident`, `CutRefused` or `NoClosedForm` when it does not, including a second cut in a drilled box); its reason reaches the script and nothing changes, shown by an `#[ignore]` curved test that takes about 27 s in a debug build. Booleans block the host thread and count against the script's 30 s budget; only Solid3D operands are accepted. | | Surface | **Complete (plane surface, extruded open profile, isolines, rigid move)** | New `SolidOperation::SurfaceFromProfile` and `SolidOperation::Extrude`, exposed as `doc.solids.surface(profile)` and `doc.solids.extrude(profile, direction)`. A closed planar profile becomes a `Plane` surface (built as the region body, so it keeps the plane kind); a profile extruded along a nonzero direction becomes a Solid3D when it is closed and a generic Surface when it is open, using OCS's own `extrusion_body`. Every payload is verified lossless by `solid_to_sat`, and rigid moves (now accepted for surfaces too) keep a plane a plane and turn any other kind generic, as OCS does after an edit. Python sees `surface_kind`, `modeler_format_version`, `point_of_reference` and `history_handle` read-only and `u_isolines` and `v_isolines` writable (0 to 200); the payload, wires, silhouettes and the kind-specific `surface_data` are excluded and preserved. The kind field is exposed as `surface_kind` because a bare `kind` key names the entity type in every Python dict; the new `exposed_property_names_are_unique_per_kind` test guards against that class of collision. `audit_python_surface_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: two plane surfaces (one on its own layer), a surface from an open polyline extrusion, three extruded solids checked against their analytic volumes (a box exactly, curved ones within 2 percent), `delete_source` erasing only the intended profile, canvas geometry, nine refusals that change nothing and record no undo step, an isoline transaction with four rejections (500, negative, the read-only kind, an excluded key), moves with exact extents, DWG and DXF reopen with lifting payloads, matching extents, kept isolines and the plane kind, a reopened surface moving again, and exact undo. Not covered: lofted, revolved, swept and NURBS surfaces (a script cannot create them; an existing one is preserved and turns generic on a move), parametric history, and AutoCAD acceptance. | -| Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. | +| Ole2Frame | **Complete** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. **Creation (new 2026-09-20):** `SolidOperation::EmbedPicture` and `doc.embed_picture(path, origin=(0, 0, 0), width=1, layer=None)` build a frame from a picture file with OCS's own `EmbeddedImage::from_file` and `build_embedded_ole`: PNG, JPEG and BMP are stored as read, any other format (a TIFF in the test) is re-encoded as PNG, `origin` is the bottom-left corner, `width` the frame width, and the height follows the picture's aspect. The generic `create_entity('Ole2Frame')` is refused with a message pointing at `doc.embed_picture`. Host validation requires finite corners spanning a nonzero size and `lock_aspect` of 0 or 1 for edits, and for creation a readable picture, a finite origin, a positive width and a non-empty layer. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver) passes C/R/E/D/U/I/W/V/P through the real runner: creation from a PNG, a corner and aspect-lock transaction, canvas geometry, five rejection cases (including an attempt to write `storage`), an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and three-step undo/redo. `audit_python_ole2frame_creation_rules_over_real_ipc` checks that PNG and JPEG are stored byte-for-byte, a TIFF becomes a PNG, the frame keeps the picture's 2:1 aspect with the requested origin and layer, and seven refusals (missing file, a non-image, empty layer, zero width, non-finite origin, a short origin list, and generic creation) change nothing and record no undo step. Not covered: OLE objects that are not pictures, and AutoCAD acceptance of the written frame, which cannot be tested here. | After each increment, update the row with test names, fixture paths, exact passing commands, the OCS commit hash, and any manual GUI result. Host and diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 05d48d57b..766aa6cf1 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -789,7 +789,7 @@ }, "Ole2Frame": { "update_only": true, - "update_only_message": "Ole2Frame cannot be created from a script: its embedded object storage is opaque; existing frames can be moved, resized and aspect-locked, and the stored object is preserved untouched", + "update_only_message": "create an Ole2Frame with doc.embed_picture(path, origin, width); existing frames can be moved, resized and aspect-locked, and the stored object is preserved untouched", "fields": { "storage": { "exclude": true, diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index c50d58b41..ec5fd8ab1 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -220,6 +220,13 @@ class _Document: handle = entity.handle if isinstance(entity, _Entity) else int(entity) ocs.remove_entity(handle) + def embed_picture(self, path, origin=(0, 0, 0), width=1, layer=None): + """Embed a picture file (PNG, JPEG, BMP or another format re-encoded as + PNG) as an OLE frame. `origin` is the bottom-left corner and `width` + the frame width; the height follows the picture's aspect ratio.""" + handle = ocs.embed_picture(str(path), [float(v) for v in origin], float(width), layer) + return self.entities[handle] + def coverage(self, kind=None): if kind is not None: return ocs.entity_coverage(kind) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 2f860727d..e4c9ce43c 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -475,6 +475,28 @@ mod ocs { .map_err(|error| vm.new_runtime_error(format!("ocs.solid_region: {error}"))) } + /// Embed a picture file as an OLE frame. + #[pyfunction] + fn embed_picture( + path: String, + origin: Vec, + width: f64, + layer: Option, + vm: &VirtualMachine, + ) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let origin: [f64; 3] = origin.try_into().map_err(|_| { + vm.new_value_error("ocs.embed_picture: the origin needs 3 numbers".to_owned()) + })?; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::EmbedPicture { path, origin, width, layer }) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.embed_picture: {error}"))) + } + /// Combine two solids ("union", "subtract" or "intersect"). #[pyfunction] fn solid_boolean( diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index dd3d531db..e6daa7ef4 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -705,6 +705,30 @@ impl<'a> HostSession<'a> { }; self.commit_profile_result(result, source, delete_source, "Extrude profile") } + SolidOperation::EmbedPicture { path, origin, width, layer } => { + finite3("picture origin", origin)?; + positive("picture width", width)?; + if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { + return Err("layer name is empty".into()); + } + let picture = crate::io::ole_embed::EmbeddedImage::from_file(std::path::Path::new(&path)) + .map_err(|error| format!("cannot read the picture {path:?}: {error}"))?; + let (upper_left, lower_right) = crate::io::ole_embed::corners_from_placement( + acadrust::types::Vector3::new(origin[0], origin[1], origin[2]), + width, + picture.aspect(), + ); + let mut frame = crate::io::ole_embed::build_embedded_ole(&picture, upper_left, lower_right); + if let Some(layer) = layer { + frame.common.layer = layer; + } + self.push_undo("Embed picture"); + let handle = self.add_entity(EntityType::Ole2Frame(frame)); + if handle.is_null() { + return Err("the picture frame could not be added".into()); + } + Ok(handle) + } SolidOperation::Boolean { first, second, operation, layer, keep_operands } => { use ocs_plugin_api::host::SolidBoolean; if first == second { @@ -4779,8 +4803,11 @@ mod tests { #[test] fn audit_python_ole2frame_lifecycle_over_real_ipc() { + image::RgbaImage::from_pixel(4, 3, image::Rgba([10, 120, 200, 255])) + .save(std::env::temp_dir().join("ocs_ole_audit.png")) + .unwrap(); run_mesh_lifecycle(&MeshCase { - create: "# MAKEOLE\n", + create: "doc.embed_picture('TMPDIR/ocs_ole_audit.png', origin=(10, 10, 0), width=20)\n", edit: concat!( "e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", @@ -5672,6 +5699,84 @@ mod tests { } } + #[test] + fn audit_python_ole2frame_creation_rules_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let dir = std::env::temp_dir(); + let png = dir.join("ocs_ole_rules.png"); + let jpeg = dir.join("ocs_ole_rules.jpg"); + let tiff = dir.join("ocs_ole_rules.tif"); + let text = dir.join("ocs_ole_rules.txt"); + let picture = image::RgbImage::from_pixel(8, 4, image::Rgb([200, 40, 40])); + picture.save(&png).unwrap(); + picture.save(&jpeg).unwrap(); + picture.save(&tiff).unwrap(); + std::fs::write(&text, "not a picture").unwrap(); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let frames = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::Ole2Frame(_))).count(); + let picture_len = |host: &HostSession<'_>, index: usize| { + let mut all: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Ole2Frame(f) => Some(f.clone()), _ => None }).collect(); + all.sort_by_key(|f| f.common.handle.value()); + match acadrust::entities::extract_presentation(&all[index].encoded_payload()) { + Some(acadrust::entities::OlePresentation::Raster(bytes)) => bytes, + other => panic!("no embedded raster: {other:?}"), + } + }; + let embed = |file: &std::path::Path, extra: &str| format!( + "PY_EVAL ocs.active_document.embed_picture({:?}, origin=(1, 2, 3), width=16{extra}).handle", file.display().to_string()); + + // C: PNG and JPEG are stored as read, a TIFF is re-encoded as PNG, and + // the frame keeps the picture's 2:1 aspect and the requested layer. + dispatch(&mut host, &embed(&png, ", layer='PICTURES'")); + dispatch(&mut host, &embed(&jpeg, "")); + dispatch(&mut host, &embed(&tiff, "")); + assert_eq!(frames(&host), 3, "{}", last(&host)); + assert_eq!(picture_len(&host, 0), std::fs::read(&png).unwrap(), "PNG is stored as read"); + assert_eq!(picture_len(&host, 1), std::fs::read(&jpeg).unwrap(), "JPEG is stored as read"); + assert_eq!(&picture_len(&host, 2)[..4], b"\x89PNG", "TIFF is re-encoded as PNG"); + let mut all: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Ole2Frame(f) => Some(f.clone()), _ => None }).collect(); + all.sort_by_key(|f| f.common.handle.value()); + assert_eq!((all[0].common.layer.as_str(), all[1].common.layer.as_str()), ("PICTURES", "0")); + let frame = &all[0]; + assert!((frame.lower_right_corner.x - frame.upper_left_corner.x - 16.0).abs() < 1e-9, "width 16"); + assert!((frame.upper_left_corner.y - frame.lower_right_corner.y - 8.0).abs() < 1e-9, "height 8 keeps the 2:1 aspect"); + assert!((frame.lower_right_corner.y - 2.0).abs() < 1e-9 && (frame.upper_left_corner.x - 1.0).abs() < 1e-9, "origin is the bottom-left corner"); + + // V: refusals change nothing and record no undo step. + let undo = host.app.tabs[0].history.undo_stack.len(); + let missing = dir.join("ocs_ole_rules_missing.png"); + for (command, message) in [ + (embed(&missing, ""), "cannot read the picture"), + (embed(&text, ""), "cannot read the picture"), + (embed(&png, ", layer=''").replace("width=16", "width=16"), "layer name is empty"), + (embed(&png, "").replace("width=16", "width=0"), "must be finite and greater than zero"), + (embed(&png, "").replace("origin=(1, 2, 3)", "origin=(float('nan'), 0, 0)"), "must be finite"), + ("ocs.embed_picture('x.png', [1.0], 5.0, None)".to_string(), "origin needs 3 numbers"), + ("ocs.active_document.create_entity('Ole2Frame', upper_left_corner=(0, 0, 0), lower_right_corner=(1, 1, 0))".to_string(), "doc.embed_picture"), + ] { + dispatch(&mut host, &format!("PY_EVAL {}", command.trim_start_matches("PY_EVAL "))); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!(frames(&host), 3); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo, "refusals record no undo step"); + for file in [&png, &jpeg, &tiff, &text] { + let _ = std::fs::remove_file(file); + } + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 6e2e073e29e8764e949a7c2e8c32cb42fea0d32f Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 18:53:32 +0200 Subject: [PATCH 48/76] Complete Viewport: layout-tab canvas oracle, unique ids, derived scale The shared driver now activates the layout that owns a viewport and requires a drawn frame. The host assigns a unique id of at least 2 and derives custom_scale as height / view_height on every scripted create or edit, as MVIEW does, so custom_scale is read-only. A control showed the default id 0 is not invisible, so the id matters for uniqueness. Co-Authored-By: Claude Sonnet 5 --- .../entity_coverage_policy.json | 4 +- crates/ocs_plugin_api/src/entity_coverage.rs | 1 - docs/plugin-host-model-completion-plan.md | 5 +- docs/plugin-host-model-coverage-ledger.md | 4 +- plugins/opencad-python/entity_manifest.json | 4 + src/app/plugin_host.rs | 100 +++++++++++++++++- 6 files changed, 106 insertions(+), 12 deletions(-) diff --git a/crates/ocs_plugin_api/entity_coverage_policy.json b/crates/ocs_plugin_api/entity_coverage_policy.json index 435d37089..79b1fe3ec 100644 --- a/crates/ocs_plugin_api/entity_coverage_policy.json +++ b/crates/ocs_plugin_api/entity_coverage_policy.json @@ -448,7 +448,6 @@ "back_clip_z", "view_height", "twist_angle", - "custom_scale", "frozen_layers", "render_mode", "circle_sides", @@ -611,7 +610,8 @@ "default_lighting_type", "brightness", "contrast", - "ambient_color" + "ambient_color", + "custom_scale" ], "ViewBorder": [ "version" diff --git a/crates/ocs_plugin_api/src/entity_coverage.rs b/crates/ocs_plugin_api/src/entity_coverage.rs index d09bca4cb..a58dba1da 100644 --- a/crates/ocs_plugin_api/src/entity_coverage.rs +++ b/crates/ocs_plugin_api/src/entity_coverage.rs @@ -2942,7 +2942,6 @@ mod tests { "Viewport.back_clip_z".to_owned(), "Viewport.view_height".to_owned(), "Viewport.twist_angle".to_owned(), - "Viewport.custom_scale".to_owned(), "Viewport.frozen_layers".to_owned(), "Viewport.render_mode".to_owned(), "Viewport.circle_sides".to_owned(), diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index d764823ee..953593033 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -9,11 +9,11 @@ As of 20 September 2026, the working branch is `plugin/host-model-api` at [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is v7. The Python feature has explicit writes for **43 of 43** canvas kinds: 41 are creatable (the legacy `Polyline` and `Body` are update-only). This is **not** 41 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D, Region, Surface and Ole2Frame pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D, Region, Surface, Ole2Frame and Viewport pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder, Light and SectionSymbol are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage and Viewport are held short of `Complete` by unverifiable or missing oracles (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. Every canvas kind now has explicit writes; SectionSymbol is mapped and integration-tested but blocked at DXF persistence by cadcodec +`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder, Light and SectionSymbol are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage is held short of `Complete` by unverifiable image-dictionary linkage (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. Every canvas kind now has explicit writes; SectionSymbol is mapped and integration-tested but blocked at DXF persistence by cadcodec writes through the document model. All 43 have raw typed snapshots. There are three internal records and two opaque fallbacks outside the 43 canvas kinds. @@ -184,3 +184,4 @@ exact queue above and update statuses with evidence after each increment. - Implemented `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect`, as recommended by the timing study. It runs synchronously, surfaces the kernel's refusal (`Coincident`, `CutRefused`, `NoClosedForm`) with a plain-language reason and the words "nothing was changed", consumes the operands unless `keep_operands`, and records one undo step. The lifecycle test uses planar boxes (millisecond cost, exact volumes, debug-safe) and an `#[ignore]` test documents a curved refusal. With this the kernel operations in the cadkernel note are all built. SectionSymbol is the only unmapped kind. - Mapped `SectionSymbol`, the last unmapped kind, so all 43 canvas kinds now have explicit writes. The blocker recorded earlier was overstated: cadcodec documents `points` as canonical, says the counts equal the point count in every verified export, and provides `sync_display_fields()`, which OCS's own edits already call; OCS also renders a symbol with no style or view link. The host derives the counts and projections when `points` changes and validates that references are existing `SectionViewStyle` and `ViewRep` class objects. Every gate passes except DXF, where cadcodec reopens a top-level `SECTIONLINE` as an unknown entity (issue 5 in the cadcodec write-up, pinned by a canary). Still unverifiable here: the meaning of `raw_flags_90` and AutoCAD's acceptance of a symbol with no parent view. - Completed `Ole2Frame` by adding creation. `SolidOperation::EmbedPicture` and `doc.embed_picture` build the frame from a picture file with OCS's own embedding code, so a script never touches the opaque storage; PNG, JPEG and BMP are stored byte-for-byte and other formats are re-encoded as PNG. The lifecycle test now creates through Python and the new creation-rules test covers formats, aspect, origin, layer and seven refusals. 30 kinds are complete. +- Completed `Viewport` by closing its missing canvas oracle and its derived state. The shared driver now switches the scene to the layout that owns a viewport and requires a drawn frame. The host assigns a unique id (at least 2) and derives `custom_scale = height / view_height` on every scripted create or edit, as MVIEW does, so `custom_scale` became read-only. A control showed my earlier suspicion was wrong: the default id 0 still draws a frame in a layout with no sheet viewport, so the id matters for uniqueness, not visibility. 31 kinds are complete. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index d072b7119..6bac2a9b6 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **43 of 43 canvas kinds have explicit geometry writes** (41 creatable, plus update-only legacy `Polyline` and `Body`), **30 kinds are complete**, and none remains layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **43 of 43 canvas kinds have explicit geometry writes** (41 creatable, plus update-only legacy `Polyline` and `Body`), **31 kinds are complete**, and none remains layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -40,7 +40,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | RasterImage | **Integration-tested** | Writable: `file_path` (creation only; a later change is rejected because the image definition owns it), `insertion_point`, `u_vector`, `v_vector` (world units per pixel), `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_boundary`. Read-only: `size` (pixel size, taken from the file), `class_version`, `definition_handle`, `definition_reactor_handle`. Creating from a script requires a readable image file: the host reads its pixel size, creates the `ImageDefinition` object, links it, and sets `size` and a whole-image clip boundary from the file. Host validation requires finite, non-parallel `u`/`v` vectors, brightness/contrast/fade within 0..=100, known display flags, finite clip vertices, at least 2 clip vertices when clipping is enabled, and an existing definition. `staged_python_raster_image_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real PNG fixture) passes C/R/E/D/U/I/W/V/P inside OCS: creation with a host-made definition, placement/scale/brightness/contrast/fade and clip edit in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with the definition link and file path intact, re-edit, and three-step undo/redo. **Why not `Complete`:** the host does not create an `ACAD_IMAGE_DICT` entry or `IMAGEDEF_REACTOR` for a scripted image, so other CAD applications may need to repair the linkage; that cannot be verified here. Editing an existing definition (file path, resolution) is out of scope. | | Wipeout | **Complete** | Writable: `insertion_point`, `u_vector`, `v_vector` (the region's world extent), `size`, `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_mode`, `clip_type` and `clip_boundary_vertices` (normalized image coordinates, -0.5..0.5). Read-only: `class_version`, `definition_handle`, `definition_reactor_handle`. Scripted creation matches OCS's native WIPEOUT command, which also leaves the definition link unset. Host validation requires finite non-parallel `u`/`v` vectors, a positive `size`, brightness/contrast/fade within 0..=100, known flags, finite clip vertices, exactly 2 vertices for a rectangular clip and at least 3 for a polygonal one. `staged_python_wipeout_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: rectangular creation, a switch to a three-vertex polygonal inside-clip with a move in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Underlay | **Integration-tested / blocked at W by acadrust (DXF rotation units)** | Writable: `underlay_type` and `definition_handle` (both fixed by the definition: creation names an existing `UnderlayDefinition`, and a mismatched type or missing definition is rejected), `insertion_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `flags`, `contrast`, `fade`, `clip_boundary_vertices`, `clip_inverted`. The file path and page live on the definition object, which scripts cannot create or list yet; they reference one the drawing already owns. Host validation requires finite nonzero scales, a finite rotation, a nonzero normal, contrast/fade within 0..=100, known flags, finite clip vertices (none, or at least 2) and an existing definition of the same type. `staged_python_underlay_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle` with a fixture PDF definition) passes C/R/E/D/U/I/V/P and DWG W: creation, a placement/scale/rotation/contrast/fade/clip transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by canary assertions):** DXF stores the rotation in degrees but the reader returns it unconverted, so 0.5 rad reopens as 28.6 and each further DXF save-reopen compounds it (1641.4 after a second), silently corrupting the rotation. Not `Complete` until the reader converts degrees back to radians. Not covered: creating definitions from a file path, DWF/DGN fixtures. | -| Viewport | **Integration-tested** | Writable: `center`, `width`, `height`, `status`, `view_center`, `view_direction`, `view_target`, `lens_length`, `front_clip_z`, `back_clip_z`, `view_height`, `twist_angle`, `custom_scale`, `frozen_layers` (layer handles), `render_mode`, `circle_sides`, `ucs_icon_visible`. Read-only: `id`, snap/grid settings, UCS fields, `clip_boundary_handle`, `style_sheet`, shade-plot, background, visual-style and sun handles, lighting, `ambient_color`, `elevation`. Reference rules: the owner must be a paper-space layout block (a model-space viewport is rejected) and every frozen-layer handle must be an existing layer. Host validation requires finite centers, positive width, height, view height, lens length and scale, a nonzero view direction, finite clip planes and twist, and at least 3 circle sides. `staged_python_viewport_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, paper-space owner supplied by the driver) passes C/R/E/D/U/V/W/P: creation in the paper-space block, a move/resize/view-height/twist/frozen-layer transaction, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. **Why not `Complete`:** the I gate lacks a canvas oracle because a paper-space viewport only draws on its layout tab, which the driver does not activate; viewport `id` uniqueness within a layout, clip boundary entities and view-to-scale synchronization are not handled by the host. | +| Viewport | **Complete (OCS round trip)** | Writable: `center`, `width`, `height`, `status`, `view_center`, `view_direction`, `view_target`, `lens_length`, `front_clip_z`, `back_clip_z`, `view_height`, `twist_angle`, `frozen_layers` (layer handles), `render_mode`, `circle_sides`, `ucs_icon_visible`. Read-only: `id`, `custom_scale`, snap and grid settings, UCS fields, `clip_boundary_handle`, `style_sheet`, shade-plot, background, visual-style and sun handles, lighting, `ambient_color`, `elevation`. Reference rules: the owner must be a paper-space layout block and every frozen-layer handle an existing layer. **Derived by the host (new 2026-09-20), as the MVIEW command does:** a scripted viewport gets an `id` unique in its layout and at least 2 (max existing plus one), and `custom_scale` is `height / view_height`, recomputed whenever either changes, so `custom_scale` is now read-only (setting the scale directly would have let it drift from the geometry). Host validation requires finite centers, positive width, height, view height, lens length and scale, a nonzero view direction, finite clip planes and twist, and at least 3 circle sides. **Canvas oracle (new):** the shared driver now activates the layout that owns the viewport (`Scene::set_current_layout`) and requires the viewport to draw a frame both after creation and after the edit. `staged_python_viewport_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/W/P: creation in the paper-space block, a move/resize/view-height/twist/frozen-layer transaction, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. `audit_python_viewport_ids_and_scale_over_real_ipc` checks unique ids (2, 3, 4), the derived scale on create and after a zoom and a resize, an id kept across edits, a rejected direct `custom_scale` write, and that every viewport draws a frame. A control showed that the engine default id 0 is **not** invisible in a layout with no sheet viewport (it draws a frame), which corrects an earlier suspicion; the derived id matters for uniqueness. Not covered: clip boundary entities (`clip_boundary_handle` is read-only and preserved), non-rectangular viewports, and AutoCAD acceptance of the written viewports, which cannot be tested here. | | ViewBorder | **Integration-tested / blocked at W by acadrust (DXF kind)** | Writable: `min`, `max`, `center` (2-D pairs), `scale`, `rotation_angle`, `active_viewport` and `scale_handle` (both required at creation). `version` is read-only. Reference rules: `active_viewport` must be an existing Viewport entity and `scale_handle` an existing `Scale` object. Host validation requires finite bounds with `min` below `max` on both axes, a positive scale and a finite rotation. `staged_python_view_border_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, driver-made viewport and scale fixtures) passes C/R/E/D/U/I/V/P and DWG W: creation, a resize/recenter/scale/rotation transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** a DXF `DRAWINGVIEW` reopens as a different, opaque entity kind rather than as a ViewBorder, so only DWG restores the typed record. Not `Complete` until DXF loading restores the typed entity. Not covered: the linked view representation and drawing-view definition records. | | SectionSymbol | **Integration-tested / blocked at W by cadcodec (DXF SECTIONLINE)** | The earlier blocker was overstated, and reading cadcodec and OCS showed why. cadcodec documents `points` as the canonical geometry, says the point-count fields equal the point count in every verified ODA export, and ships `sync_display_fields()` to refresh the `end_a`/`end_b`/`tick_a`/`tick_b`/`label` projections; OCS's own grip and transform code already calls it. OCS also renders a symbol with no style and no view link. So the kind is mapped: writable `points` (each `{point, bulge, label, label_offset, raw_flag_280}`), `symbol_scale`, `style_handle` and `view_rep_handle`; read-only `view_symbol_version`, `raw_view_symbol_70`, `version`, `raw_flags_90`, the two derived counts and the five projections. The host sets `raw_point_count_90` and `raw_point_record_count` to the point count and calls `sync_display_fields()` whenever `points` is created or changed, and leaves a symbol whose points did not change exactly as read. Host validation requires at least two finite points with finite bulges and label offsets, a positive scale, and that each non-null reference is an existing `SectionViewStyle` (style) or `ViewRep` (view representation) class object; the wrong object kind is refused. Creating one without references gives a standalone symbol. `audit_python_section_symbol_lifecycle_over_real_ipc` (shared driver) passes C/R/E/D/U/I/V/P and DWG W: a two-point symbol, a three-point edit with a new label, bulge and scale in one transaction (counts and projections follow), canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, three-step undo/redo. `audit_python_section_symbol_references_over_real_ipc` covers valid references, a standalone symbol and four wrong-object refusals with host-made class objects. **W blocker (cadcodec, pinned by a canary; issue 5 in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md)):** the DXF reader dispatches `SECTIONLINE` only inside blocks, so a symbol in the drawing's entity list reopens as an `Unknown` entity. Open items that cannot be checked here: `raw_flags_90` has unknown semantics (0 for a new symbol, preserved on read), and whether AutoCAD accepts a symbol with no parent view. Scripts cannot create the style or view-representation objects. | | Light | **Integration-tested / blocked at W by acadrust (DXF `cast_shadows`)** | Writable: `name`, `light_type` (1 distant, 2 point, 3 spot), `position`, `target`, `status`, `light_color`, `plot_glyph`, `intensity`, the attenuation type and limits, `hotspot_angle`, `falloff_angle`, `cast_shadows`, `shadow_type`, `shadow_map_size`, `shadow_map_softness`, `photometric_mode`, `photometric_data`; `class_version` is read-only. Host validation requires a non-empty name, a known light type, finite placement, a spot or distant target that differs from the position, non-negative intensity, angles and limits, an attenuation start not above its end, a spot hotspot not above its falloff, and a non-negative shadow map size. `staged_python_light_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P and DWG W: a spot light with an RGB colour, an intensity/target/position/shadow transaction that preserves the colour and untouched fields, the canvas glyph, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary):** the DXF reader does not restore `cast_shadows`, which reopens false; colour, placement and intensity persist. Not covered: photometric web data edits and sun/skylight objects. | diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 766aa6cf1..298087fed 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -752,6 +752,10 @@ }, "height": { "required": true + }, + "custom_scale": { + "read_only": true, + "_comment": "Derived by the host as height / view_height, like MVIEW." } } }, diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index e6daa7ef4..3c1628a9c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -158,6 +158,26 @@ impl<'a> HostSession<'a> { if let EntityType::Dimension(dimension) = entity { crate::entities::dimension::normalize_scripted_dimension(dimension); } + if let EntityType::Viewport(viewport) = entity { + // A viewport needs an id unique in its layout and at least 2 (id 1 + // is the never-drawn sheet viewport, and a lone id 0 can be taken for + // it), and its scale follows its paper height and model view height; + // both are what the MVIEW command sets. + if old.is_none() { + let owner = viewport.common.owner_handle; + let highest = self + .document() + .entities() + .filter_map(|e| match e { + EntityType::Viewport(other) if other.common.owner_handle == owner => Some(other.id), + _ => None, + }) + .max() + .unwrap_or(1); + viewport.id = (highest + 1).max(2); + } + viewport.custom_scale = viewport.height / viewport.view_height; + } if let EntityType::SectionSymbol(symbol) = entity { // `points` is the canonical geometry: the point counts follow it // (they equal the point count in every verified export) and the @@ -3682,12 +3702,18 @@ mod tests { .unwrap_or_else(|| panic!("Python did not create the mesh: {}", last_output(&host))); let created = host.document().get_entity(handle).unwrap().clone(); assert_eq!((case.digest)(&created), case.expect_created); - // Viewports draw only on their paper-space layout tab, which this - // driver does not activate, so they have no model-tab wire oracle. - let has_wires = !matches!(created, EntityType::Viewport(_)); - if has_wires { - assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); + // A paper-space viewport draws only on its layout tab, so the canvas + // oracle first activates the layout that owns it. + if matches!(created, EntityType::Viewport(_)) { + let owner = created.common().owner_handle; + let layout = host.document().objects.values().find_map(|object| match object { + acadrust::objects::ObjectType::Layout(l) if l.block_record == owner => Some(l.name.clone()), + _ => None, + }).expect("the viewport's owner is a layout block"); + host.app.tabs[0].scene.set_current_layout(layout); } + let has_wires = true; + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "no canvas geometry"); run_script(&mut host, "edit", &case.edit.replace("HANDLE", &handle.value().to_string()).replace("TMPDIR", &tmp).replace("LAYER0", &layer0.unwrap_or_default().to_string())); let expected = host.document().get_entity(handle).unwrap().clone(); @@ -5777,6 +5803,70 @@ mod tests { } } + #[test] + fn audit_python_viewport_ids_and_scale_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let paper = host.document().block_records.iter().find(|r| r.is_paper_space()).map(|r| r.handle).unwrap(); + let layout = host.document().objects.values().find_map(|o| match o { + acadrust::objects::ObjectType::Layout(l) if l.block_record == paper => Some(l.name.clone()), _ => None }).unwrap(); + host.app.tabs[0].scene.set_current_layout(layout); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let viewports = |host: &HostSession<'_>| { + let mut list: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Viewport(v) => Some((v.common.handle, v.id)), _ => None }).collect(); + list.sort_by_key(|(h, _)| h.value()); + list + }; + let create = |width: f64, height: f64, view_height: f64| format!( + "PY_EVAL ocs.active_document.create_entity('Viewport', owner_handle={}, center=(50, 50, 0), width={width}, height={height}, view_height={view_height}).handle", paper.value()); + dispatch(&mut host, &create(80.0, 60.0, 50.0)); + dispatch(&mut host, &create(40.0, 30.0, 10.0)); + dispatch(&mut host, &create(20.0, 20.0, 20.0)); + let list = viewports(&host); + assert_eq!(list.len(), 3, "{}", last(&host)); + let ids: Vec = list.iter().map(|(_, id)| *id).collect(); + assert_eq!(ids, vec![2, 3, 4], "scripted viewports get unique ids of at least 2: {ids:?}"); + for (handle, expected) in [(list[0].0, 60.0 / 50.0), (list[1].0, 30.0 / 10.0), (list[2].0, 1.0)] { + let Some(EntityType::Viewport(v)) = host.document().get_entity(handle) else { unreachable!() }; + assert!((v.custom_scale - expected).abs() < 1e-12, "custom_scale {} vs {expected}", v.custom_scale); + assert!(!host.app.tabs[0].scene.wire_models_for(&[handle]).is_empty(), "viewport {handle:?} draws a frame"); + } + // The scale follows the geometry; it cannot be set directly. + let second = list[0].0; + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Zoom', [{{'handle':{}, 'view_height':25.0}}])", second.value())); + let Some(EntityType::Viewport(zoomed)) = host.document().get_entity(second) else { unreachable!() }; + assert!((zoomed.custom_scale - 60.0 / 25.0).abs() < 1e-12, "{}", last(&host)); + assert_eq!(zoomed.id, 2, "an edit keeps the id"); + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Resize', [{{'handle':{}, 'height':90.0}}])", second.value())); + let Some(EntityType::Viewport(resized)) = host.document().get_entity(second) else { unreachable!() }; + assert!((resized.custom_scale - 90.0 / 25.0).abs() < 1e-12); + let before = host.document().get_entity(second).unwrap().clone(); + dispatch(&mut host, &format!("PY_EVAL ocs.update_many('Scale', [{{'handle':{}, 'custom_scale':3.0}}])", second.value())); + assert!(last(&host).contains("read-only"), "{}", last(&host)); + assert_eq!(host.document().get_entity(second), Some(&before)); + // Control: the engine's default id 0 is not invisible here (there is no + // sheet viewport to confuse it with), so the derived id matters for + // uniqueness, not for visibility. + let mut bare = acadrust::entities::Viewport::new(); + bare.common.owner_handle = paper; + bare.width = 40.0; + bare.height = 30.0; + let bare = host.document_mut().add_entity(EntityType::Viewport(bare)).unwrap(); + assert_eq!(host.document().get_entity(bare).map(|e| match e { EntityType::Viewport(v) => v.id, _ => -1 }), Some(0)); + assert!(!host.app.tabs[0].scene.wire_models_for(&[bare]).is_empty()); + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From 7c959c8395865a9259989db3f8f4ccf94e80e60c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 19:00:16 +0200 Subject: [PATCH 49/76] Register scripted image definitions in ACAD_IMAGE_DICT and reuse per file A scripted RasterImage now reuses one ImageDefinition per file and registers it in ACAD_IMAGE_DICT, which owns it, as for an AutoCAD-attached image. The reactor is deliberately not written because its owner convention cannot be verified. An ignored spike records that OCS's own IMAGE command creates bare images that lose their file path on save. Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 1 + docs/plugin-host-model-coverage-ledger.md | 2 +- src/app/plugin_host.rs | 198 ++++++++++++++++++++-- 3 files changed, 189 insertions(+), 12 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 953593033..87cf50476 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -185,3 +185,4 @@ exact queue above and update statuses with evidence after each increment. - Mapped `SectionSymbol`, the last unmapped kind, so all 43 canvas kinds now have explicit writes. The blocker recorded earlier was overstated: cadcodec documents `points` as canonical, says the counts equal the point count in every verified export, and provides `sync_display_fields()`, which OCS's own edits already call; OCS also renders a symbol with no style or view link. The host derives the counts and projections when `points` changes and validates that references are existing `SectionViewStyle` and `ViewRep` class objects. Every gate passes except DXF, where cadcodec reopens a top-level `SECTIONLINE` as an unknown entity (issue 5 in the cadcodec write-up, pinned by a canary). Still unverifiable here: the meaning of `raw_flags_90` and AutoCAD's acceptance of a symbol with no parent view. - Completed `Ole2Frame` by adding creation. `SolidOperation::EmbedPicture` and `doc.embed_picture` build the frame from a picture file with OCS's own embedding code, so a script never touches the opaque storage; PNG, JPEG and BMP are stored byte-for-byte and other formats are re-encoded as PNG. The lifecycle test now creates through Python and the new creation-rules test covers formats, aspect, origin, layer and seven refusals. 30 kinds are complete. - Completed `Viewport` by closing its missing canvas oracle and its derived state. The shared driver now switches the scene to the layout that owns a viewport and requires a drawn frame. The host assigns a unique id (at least 2) and derives `custom_scale = height / view_height` on every scripted create or edit, as MVIEW does, so `custom_scale` became read-only. A control showed my earlier suspicion was wrong: the default id 0 still draws a frame in a layout with no sheet viewport, so the id matters for uniqueness, not visibility. 31 kinds are complete. +- Improved `RasterImage` linkage. A scripted image now reuses one definition per file and registers it in `ACAD_IMAGE_DICT`, which owns it; this persists through DWG and DXF. The `IMAGEDEF_REACTOR` is deliberately not written: its owner convention is ambiguous between cadcodec's struct docs and its DXF writer and cannot be checked against AutoCAD here, so the kind stays integration-tested. An exploratory spike also showed that OCS's own IMAGE command creates bare images that lose their file path on save, an OCS bug outside this plugin work that is flagged separately. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 6bac2a9b6..6d9c346a7 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -37,7 +37,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | PolyfaceMesh | **Complete** | Writable: `elevation`, `flags`, `normal`, `start_width`, `end_width`, `smooth_surface`, `thickness`, `vertices` (location, flags, bulge, widths, tangent, id) and `faces` (four indices, flags, optional colour). `seqend_handle` is read-only and preserved. Host validation requires at least 3 finite vertices and 1 face, every face index within the vertex count (a negative index hides that edge, 0 marks an unused corner), at least 3 indices per face, and finite widths, thickness and elevation. `staged_python_polyface_mesh_lifecycle_over_real_ipc` passes the full gate: two triangles, a vertex move plus a new vertex and a hidden-edge face in one transaction, canvas wires, six rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Mesh | **Complete** | Writable: `blend_crease`, `subdivision_level`, `vertices`, `faces`, `edges` (start, end, optional crease), `override_option`; `version` is read-only. Host validation requires at least 3 finite vertices, faces of at least 3 in-range indices, edges with distinct in-range endpoints and a finite non-negative crease, and a non-negative subdivision level, so out-of-range topology is rejected before mutation. `staged_python_mesh_lifecycle_over_real_ipc` passes the full gate: a quad with a creased edge, an added vertex and triangle plus subdivision level 1 in one transaction, canvas wires, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Helix | **Integration-tested / blocked at W by acadrust (DXF handedness)** | Writable generating parameters: `axis_base_point`, `start_point`, `axis_vector`, `radius` (the top radius; the base radius is the start point's distance from the axis), `turns`, `turn_height`, `handedness`, `constraint`. Read-only: `spline`, `major_version`, `maintenance_version`. The host rebuilds the derived NURBS spline with OCS's own helix builder on every scripted create or parameter edit (`normalize_scripted_helix`), so the spline always matches the parameters. Host validation requires finite positive radius, turns and turn height, a nonzero axis and a start point off the axis. `staged_python_helix_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/V/P: creation with a derived spline ending at z = turns × turn height, a turns and handedness edit that regenerates it, canvas wires, seven rejection cases plus an atomic rollback, DWG reopen and re-edit, DXF reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by a canary assertion):** the DXF reader parses boolean group 290 as an `i16` and never applies it, so a clockwise helix reopens counter-clockwise from DXF while DWG keeps it. Not `Complete` until the reader is fixed. | -| RasterImage | **Integration-tested** | Writable: `file_path` (creation only; a later change is rejected because the image definition owns it), `insertion_point`, `u_vector`, `v_vector` (world units per pixel), `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_boundary`. Read-only: `size` (pixel size, taken from the file), `class_version`, `definition_handle`, `definition_reactor_handle`. Creating from a script requires a readable image file: the host reads its pixel size, creates the `ImageDefinition` object, links it, and sets `size` and a whole-image clip boundary from the file. Host validation requires finite, non-parallel `u`/`v` vectors, brightness/contrast/fade within 0..=100, known display flags, finite clip vertices, at least 2 clip vertices when clipping is enabled, and an existing definition. `staged_python_raster_image_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real PNG fixture) passes C/R/E/D/U/I/W/V/P inside OCS: creation with a host-made definition, placement/scale/brightness/contrast/fade and clip edit in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with the definition link and file path intact, re-edit, and three-step undo/redo. **Why not `Complete`:** the host does not create an `ACAD_IMAGE_DICT` entry or `IMAGEDEF_REACTOR` for a scripted image, so other CAD applications may need to repair the linkage; that cannot be verified here. Editing an existing definition (file path, resolution) is out of scope. | +| RasterImage | **Integration-tested** | Writable: `file_path` (creation only; a later change is rejected because the image definition owns it), `insertion_point`, `u_vector`, `v_vector` (world units per pixel), `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_boundary`. Read-only: `size` (pixel size, taken from the file), `class_version`, `definition_handle`, `definition_reactor_handle`. Creating from a script requires a readable image file: the host reads its pixel size, creates the `ImageDefinition` object, links it, and sets `size` and a whole-image clip boundary from the file. Host validation requires finite, non-parallel `u`/`v` vectors, brightness/contrast/fade within 0..=100, known display flags, finite clip vertices, at least 2 clip vertices when clipping is enabled, and an existing definition. `staged_python_raster_image_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`, real PNG fixture) passes C/R/E/D/U/I/W/V/P inside OCS: creation with a host-made definition, placement/scale/brightness/contrast/fade and clip edit in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with the definition link and file path intact, re-edit, and three-step undo/redo. **Linkage (updated 2026-09-20):** a scripted image now reuses one `ImageDefinition` per file and registers each definition in `ACAD_IMAGE_DICT` under the file's name (a `_2`, `_3` suffix keeps keys unique), creating that dictionary under the root when the drawing has none, and the dictionary owns the definition, as for an image AutoCAD attached. `audit_python_raster_image_definition_linkage_over_real_ipc` checks two images of one file sharing a definition, a third file getting its own, the entries resolving with the right owner, and all of it surviving DWG and DXF reopen and an undo without a dangling entry. **Why not `Complete`:** the host still writes no `IMAGEDEF_REACTOR`: cadcodec's struct documents the reactor's owner as the definition while its DXF writer follows a different convention, and AutoCAD's behavior cannot be checked here, so guessing risks a structure AutoCAD repairs or rejects. **Related finding:** OCS's own IMAGE command creates a bare `RasterImage` with no definition, and a save through either writer drops its file path (`spike_raster_image_definition_persistence`), while the definition-linked form keeps it. Editing an existing definition (file path, resolution) is out of scope. | | Wipeout | **Complete** | Writable: `insertion_point`, `u_vector`, `v_vector` (the region's world extent), `size`, `flags`, `clipping_enabled`, `brightness`, `contrast`, `fade`, `clip_mode`, `clip_type` and `clip_boundary_vertices` (normalized image coordinates, -0.5..0.5). Read-only: `class_version`, `definition_handle`, `definition_reactor_handle`. Scripted creation matches OCS's native WIPEOUT command, which also leaves the definition link unset. Host validation requires finite non-parallel `u`/`v` vectors, a positive `size`, brightness/contrast/fade within 0..=100, known flags, finite clip vertices, exactly 2 vertices for a rectangular clip and at least 3 for a polygonal one. `staged_python_wipeout_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle`) passes C/R/E/D/U/I/W/V/P: rectangular creation, a switch to a three-vertex polygonal inside-clip with a move in one transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. | | Underlay | **Integration-tested / blocked at W by acadrust (DXF rotation units)** | Writable: `underlay_type` and `definition_handle` (both fixed by the definition: creation names an existing `UnderlayDefinition`, and a mismatched type or missing definition is rejected), `insertion_point`, `x_scale`, `y_scale`, `z_scale`, `rotation`, `normal`, `flags`, `contrast`, `fade`, `clip_boundary_vertices`, `clip_inverted`. The file path and page live on the definition object, which scripts cannot create or list yet; they reference one the drawing already owns. Host validation requires finite nonzero scales, a finite rotation, a nonzero normal, contrast/fade within 0..=100, known flags, finite clip vertices (none, or at least 2) and an existing definition of the same type. `staged_python_underlay_lifecycle_over_real_ipc` (shared `run_mesh_lifecycle` with a fixture PDF definition) passes C/R/E/D/U/I/V/P and DWG W: creation, a placement/scale/rotation/contrast/fade/clip transaction, canvas wires, eight rejection cases plus an atomic rollback, DWG reopen and re-edit, and three-step undo/redo. **W blocker (engine, pinned by canary assertions):** DXF stores the rotation in degrees but the reader returns it unconverted, so 0.5 rad reopens as 28.6 and each further DXF save-reopen compounds it (1641.4 after a second), silently corrupting the rotation. Not `Complete` until the reader converts degrees back to radians. Not covered: creating definitions from a file path, DWF/DGN fixtures. | | Viewport | **Complete (OCS round trip)** | Writable: `center`, `width`, `height`, `status`, `view_center`, `view_direction`, `view_target`, `lens_length`, `front_clip_z`, `back_clip_z`, `view_height`, `twist_angle`, `frozen_layers` (layer handles), `render_mode`, `circle_sides`, `ucs_icon_visible`. Read-only: `id`, `custom_scale`, snap and grid settings, UCS fields, `clip_boundary_handle`, `style_sheet`, shade-plot, background, visual-style and sun handles, lighting, `ambient_color`, `elevation`. Reference rules: the owner must be a paper-space layout block and every frozen-layer handle an existing layer. **Derived by the host (new 2026-09-20), as the MVIEW command does:** a scripted viewport gets an `id` unique in its layout and at least 2 (max existing plus one), and `custom_scale` is `height / view_height`, recomputed whenever either changes, so `custom_scale` is now read-only (setting the scale directly would have let it drift from the geometry). Host validation requires finite centers, positive width, height, view height, lens length and scale, a nonzero view direction, finite clip planes and twist, and at least 3 circle sides. **Canvas oracle (new):** the shared driver now activates the layout that owns the viewport (`Scene::set_current_layout`) and requires the viewport to draw a frame both after creation and after the edit. `staged_python_viewport_lifecycle_over_real_ipc` passes C/R/E/D/U/I/V/W/P: creation in the paper-space block, a move/resize/view-height/twist/frozen-layer transaction, seven rejection cases plus an atomic rollback, DWG and DXF reopen with re-edit, and three-step undo/redo. `audit_python_viewport_ids_and_scale_over_real_ipc` checks unique ids (2, 3, 4), the derived scale on create and after a zoom and a resize, an id kept across edits, a rejected direct `custom_scale` write, and that every viewport draws a frame. A control showed that the engine default id 0 is **not** invisible in a layout with no sheet viewport (it draws a frame), which corrects an earlier suspicion; the derived id matters for uniqueness. Not covered: clip boundary entities (`clip_boundary_handle` is read-only and preserved), non-rectangular viewports, and AutoCAD acceptance of the written viewports, which cannot be tested here. | diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 3c1628a9c..6b38b0ca5 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -220,6 +220,30 @@ impl<'a> HostSession<'a> { Ok(()) } + /// The `ACAD_IMAGE_DICT` dictionary under the named-objects root, created + /// when the drawing has none. `None` when the drawing has no root dictionary. + fn ensure_image_dictionary(&mut self) -> Option { + use acadrust::objects::{Dictionary, ObjectType}; + let root = self.document().header.named_objects_dict_handle; + let Some(ObjectType::Dictionary(root_dictionary)) = self.document().objects.get(&root) else { + return None; + }; + if let Some(existing) = root_dictionary.get("ACAD_IMAGE_DICT") { + if matches!(self.document().objects.get(&existing), Some(ObjectType::Dictionary(_))) { + return Some(existing); + } + } + let handle = self.document_mut().allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = handle; + dictionary.owner = root; + self.document_mut().objects.insert(handle, ObjectType::Dictionary(dictionary)); + if let Some(ObjectType::Dictionary(root_dictionary)) = self.document_mut().objects.get_mut(&root) { + root_dictionary.add_entry("ACAD_IMAGE_DICT", handle); + } + Some(handle) + } + /// A scripted RasterImage names a file; the host creates the image /// definition object it needs (pixel size read from the file) and links it. fn prepare_scripted_raster_image(&mut self, entity: &mut EntityType) -> Result<(), String> { @@ -231,17 +255,51 @@ impl<'a> HostSession<'a> { } let (width, height) = image::image_dimensions(&image.file_path) .map_err(|error| format!("cannot read image {:?}: {error}", image.file_path))?; - let handle = self.document_mut().allocate_handle(); - let mut definition = acadrust::objects::ImageDefinition::with_dimensions( - image.file_path.clone(), - width, - height, - ); - definition.handle = handle; - definition.is_loaded = true; - self.document_mut() - .objects - .insert(handle, acadrust::objects::ObjectType::ImageDefinition(definition)); + // One definition per file, as AutoCAD keeps it: reuse an existing one. + let existing = self.document().objects.iter().find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::ImageDefinition(definition) + if definition.file_name == image.file_path => Some(*handle), + _ => None, + }); + let handle = match existing { + Some(handle) => handle, + None => { + let handle = self.document_mut().allocate_handle(); + let mut definition = acadrust::objects::ImageDefinition::with_dimensions( + image.file_path.clone(), + width, + height, + ); + definition.handle = handle; + definition.is_loaded = true; + // Register it in ACAD_IMAGE_DICT under the file's name, and let + // that dictionary own it, like an image AutoCAD attached. + let stem = std::path::Path::new(&image.file_path) + .file_stem() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or("IMAGE") + .to_owned(); + if let Some(dictionary) = self.ensure_image_dictionary() { + definition.owner = dictionary; + if let Some(acadrust::objects::ObjectType::Dictionary(entries)) = + self.document_mut().objects.get_mut(&dictionary) + { + let mut key = stem.clone(); + let mut suffix = 1; + while entries.get(&key).is_some() { + suffix += 1; + key = format!("{stem}_{suffix}"); + } + entries.add_entry(key, handle); + } + } + self.document_mut() + .objects + .insert(handle, acadrust::objects::ObjectType::ImageDefinition(definition)); + handle + } + }; image.definition_handle = Some(handle); // `size` is the image's pixel size, and an untouched clip boundary // covers the whole image, so both follow the file. @@ -5867,6 +5925,124 @@ mod tests { assert!(!host.app.tabs[0].scene.wire_models_for(&[bare]).is_empty()); } + /// Evidence for the RasterImage ledger row: what a save does with a bare + /// image (OCS's native IMAGE command) and with a definition-linked one. + #[test] + #[ignore = "exploratory evidence"] + fn spike_raster_image_definition_persistence() { + let dir = std::env::temp_dir(); + let path = dir.join("ocs_spike_image.png").to_string_lossy().into_owned(); + image::RgbaImage::from_pixel(8, 4, image::Rgba([1, 2, 3, 255])).save(&path).unwrap(); + for linked in [false, true] { + let mut doc = acadrust::CadDocument::new(); + let mut img = acadrust::entities::RasterImage::with_size(&path, acadrust::types::Vector3::ZERO, 8.0, 4.0, 16.0, 8.0); + if linked { + let h = doc.allocate_handle(); + let mut def = acadrust::objects::ImageDefinition::with_dimensions(path.clone(), 8, 4); + def.handle = h; + def.is_loaded = true; + doc.objects.insert(h, acadrust::objects::ObjectType::ImageDefinition(def)); + img.definition_handle = Some(h); + } + let handle = doc.add_entity(EntityType::RasterImage(img)).unwrap(); + for (label, document) in [ + ("DWG", crate::io::load_bytes("i.dwg", acadrust::DwgWriter::write_to_vec(&doc).unwrap()).unwrap()), + ("DXF", crate::io::load_bytes("i.dxf", acadrust::DxfWriter::new(&doc).write_to_vec().unwrap()).unwrap()), + ] { + let entity = document.get_entity(handle); + let defs = document.objects.values().filter(|o| matches!(o, acadrust::objects::ObjectType::ImageDefinition(_))).count(); + let reactors = document.objects.values().filter(|o| matches!(o, acadrust::objects::ObjectType::ImageDefinitionReactor(_))).count(); + let dicts = document.objects.values().filter(|o| matches!(o, acadrust::objects::ObjectType::Dictionary(d) if d.entries.iter().any(|(k, _)| k.contains("IMAGE")))).count(); + match entity { + Some(EntityType::RasterImage(i)) => eprintln!("SPIKE linked={linked} {label}: file_path_ok={} definition={:?} reactor={:?} defs={defs} reactors={reactors} image_dicts={dicts}", + i.file_path == path, i.definition_handle.map(|h| h.value()), i.definition_reactor_handle.map(|h| h.value())), + other => eprintln!("SPIKE linked={linked} {label}: came back as {:?}", other.map(|e| format!("{e:?}").chars().take(24).collect::())), + } + } + } + } + + #[test] + fn audit_python_raster_image_definition_linkage_over_real_ipc() { + use acadrust::objects::ObjectType; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let dir = std::env::temp_dir(); + let (a, b) = (dir.join("ocs_link_a.png"), dir.join("ocs_link_b.png")); + image::RgbaImage::from_pixel(8, 4, image::Rgba([9, 9, 9, 255])).save(&a).unwrap(); + image::RgbaImage::from_pixel(6, 6, image::Rgba([90, 9, 9, 255])).save(&b).unwrap(); + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let script = dir.join(format!("ocs_link_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\n", + "doc.create_entity('RasterImage', file_path={a:?}, insertion_point=(0, 0, 0))\n", + "doc.create_entity('RasterImage', file_path={a:?}, insertion_point=(20, 0, 0))\n", + "doc.create_entity('RasterImage', file_path={b:?}, insertion_point=(40, 0, 0))\n", + ), a = a.display().to_string(), b = b.display().to_string())).unwrap(); + assert!(process.dispatch(&mut host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + + // The structure: one definition per file, registered and owned by ACAD_IMAGE_DICT. + let structure = |document: &CadDocument| { + let root = document.header.named_objects_dict_handle; + let Some(ObjectType::Dictionary(root_dict)) = document.objects.get(&root) else { panic!("no root dictionary") }; + let dict_handle = root_dict.get("ACAD_IMAGE_DICT"); + let entries = dict_handle.and_then(|h| match document.objects.get(&h) { + Some(ObjectType::Dictionary(d)) => Some(d.entries.clone()), _ => None }).unwrap_or_default(); + let defs: Vec<_> = document.objects.iter().filter_map(|(h, o)| match o { + ObjectType::ImageDefinition(d) => Some((*h, d.owner, d.file_name.clone())), _ => None }).collect(); + let mut images: Vec<_> = document.entities().filter_map(|e| match e { + EntityType::RasterImage(i) => Some((i.common.handle, i.definition_handle, i.file_path.clone())), _ => None }).collect(); + images.sort_by_key(|(h, _, _)| h.value()); + (dict_handle, entries, defs, images) + }; + let check = |label: &str, document: &CadDocument| { + let (dict, entries, defs, images) = structure(document); + let dict = dict.unwrap_or_else(|| panic!("{label}: no ACAD_IMAGE_DICT")); + assert_eq!(defs.len(), 2, "{label}: one definition per file"); + let mut keys: Vec<_> = entries.iter().map(|(k, _)| k.as_str()).collect(); + keys.sort(); + assert_eq!(keys, vec!["ocs_link_a", "ocs_link_b"], "{label}"); + for (key, handle) in &entries { + let (_, owner, file) = defs.iter().find(|(h, _, _)| h == handle).unwrap_or_else(|| panic!("{label}: dangling entry {key}")); + assert_eq!(*owner, dict, "{label}: {key} is owned by ACAD_IMAGE_DICT"); + assert!(file.contains(key.as_str()), "{label}: {key} -> {file}"); + } + assert_eq!(images.len(), 3, "{label}"); + assert_eq!(images[0].1, images[1].1, "{label}: images of one file share a definition"); + assert_ne!(images[0].1, images[2].1, "{label}"); + for (_, definition, path) in &images { + assert!(defs.iter().any(|(h, _, _)| Some(*h) == *definition), "{label}: definition resolves"); + assert!(path.contains("ocs_link_"), "{label}: file path kept: {path}"); + } + }; + check("live", host.document()); + let dwg = crate::io::load_bytes("link.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("link.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check("DWG", &dwg); + check("DXF", &dxf); + + // U: undoing the last image leaves no dangling dictionary entry. + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(1); + let (_, entries, defs, images) = structure(&app.tabs[0].scene.document); + assert!(images.len() < 3); + for (key, handle) in &entries { + assert!(defs.iter().any(|(h, _, _)| h == handle), "undo left a dangling entry {key}"); + } + for file in [&a, &b] { + let _ = std::fs::remove_file(file); + } + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { From b7b6e2cef576990598c76a9e7c3969ac5a24a103 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 16:00:26 +0200 Subject: [PATCH 50/76] feat(python): validated, undoable layer table (doc.layers) Adds the additive v7 TableOperation request and HostApi::table_operation for layer create/modify/rename/delete/set-current. Refusals are decided before the undo step is recorded. Python wrapper, real-IPC audit with DWG/DXF persistence, IPC round-trip test, Python unit test and docs. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 25 ++ crates/ocs_plugin_api/src/ipc/client.rs | 12 + crates/ocs_plugin_api/src/ipc/protocol.rs | 3 + crates/ocs_plugin_api/src/ipc/server.rs | 1 + crates/ocs_plugin_api/src/ipc/v4/client.rs | 12 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 29 ++ docs/plugin-host-model-completion-plan.md | 14 + plugins/opencad-python/PLUGIN.md | 26 ++ plugins/opencad-python/src/document_model.py | 82 +++++ plugins/opencad-python/src/ocs_module.rs | 159 ++++++++++ .../tests/test_document_model.py | 18 ++ src/app/plugin_host.rs | 300 ++++++++++++++++++ 12 files changed, 681 insertions(+) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index eff68422c..029a43ec0 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -85,6 +85,25 @@ pub enum SolidBoolean { Intersect, } +/// 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 }, +} + /// 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)] @@ -690,6 +709,12 @@ pub trait HostApi { fn solid_operation(&mut self, _operation: SolidOperation) -> Result { Err("solid operations are 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 { + 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. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 16fd59671..c4dba1ac8 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -482,6 +482,18 @@ impl HostApi for PluginHostApi { } } + fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result { + 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, diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 042e0fb2e..a69d501ca 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -155,6 +155,8 @@ pub enum PluginRequest { SetSelection { handles: Vec }, /// 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 }, } /// Responses the host sends back for `PluginRequest`. @@ -189,6 +191,7 @@ pub enum PluginResponse { Selection(Vec), SelectionResult(Result<(), String>), SolidResult(Result), + TableResult(Result), } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 3b3c39592..46aab7f84 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -110,5 +110,6 @@ pub fn handle_plugin_request( 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)), } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 43c43e3ba..2ab6c7c66 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -796,6 +796,18 @@ impl HostApi for V4PluginHostApi { } } + fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result { + 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, diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 3e448ea89..fd8a65cff 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -105,6 +105,35 @@ mod tests { assert_eq!(decoded, changed); } + #[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() }, + ] { + let bytes = bincode::serialize(&PluginRequest::TableOperation { operation: operation.clone() }).unwrap(); + assert!(matches!(bincode::deserialize::(&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::(&bytes).unwrap(), + PluginResponse::TableResult(decoded) if decoded == result)); + } + } + #[test] fn solid_operation_request_and_response_roundtrip() { use crate::host::{SolidOperation, SolidPrimitive}; diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 87cf50476..a7f813499 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -186,3 +186,17 @@ exact queue above and update statuses with evidence after each increment. - Completed `Ole2Frame` by adding creation. `SolidOperation::EmbedPicture` and `doc.embed_picture` build the frame from a picture file with OCS's own embedding code, so a script never touches the opaque storage; PNG, JPEG and BMP are stored byte-for-byte and other formats are re-encoded as PNG. The lifecycle test now creates through Python and the new creation-rules test covers formats, aspect, origin, layer and seven refusals. 30 kinds are complete. - Completed `Viewport` by closing its missing canvas oracle and its derived state. The shared driver now switches the scene to the layout that owns a viewport and requires a drawn frame. The host assigns a unique id (at least 2) and derives `custom_scale = height / view_height` on every scripted create or edit, as MVIEW does, so `custom_scale` became read-only. A control showed my earlier suspicion was wrong: the default id 0 still draws a frame in a layout with no sheet viewport, so the id matters for uniqueness, not visibility. 31 kinds are complete. - Improved `RasterImage` linkage. A scripted image now reuses one definition per file and registers it in `ACAD_IMAGE_DICT`, which owns it; this persists through DWG and DXF. The `IMAGEDEF_REACTOR` is deliberately not written: its owner convention is ambiguous between cadcodec's struct docs and its DXF writer and cannot be checked against AutoCAD here, so the kind stays integration-tested. An exploratory spike also showed that OCS's own IMAGE command creates bare images that lose their file path on save, an OCS bug outside this plugin work that is flagged separately. + +### 2026-09-21: beyond entities, the layer table (`doc.layers`) + +Entity CRUD is at its ceiling without the cadcodec fixes (PR HakanSeven12/cadcodec#48), so work moved to +drawing objects a script needs. New additive v7 request `TableOperation` (`LayerCreate`, `LayerModify`, +`LayerRename`, `LayerDelete { erase_objects }`, `LayerSetCurrent`) with `HostApi::table_operation`, +executed by `HostSession::table_operation`. Every refusal (invalid or duplicate name, bad color, +unknown linetype, out-of-range lineweight or transparency, protected layer `0`/`Defpoints`, current +layer, layer holding objects without `erase_objects`) is decided before the undo step is recorded, so +nothing changes. Python: `ocs.active_document.layers` (`create`, `modify`, `rename`, `delete`, +`set_current`, `current`, lookup and iteration). Evidence: `audit_python_layer_table_over_real_ipc` +(19 refusals, live/DWG/DXF persistence of colour, lineweight, transparency, flags, linetype, +description, rename following entities, `erase_objects` and one-step undo), an IPC round-trip test and a +Python unit test. Next: text and dimension styles, linetypes, blocks, layouts; then headless commands. diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index f885cdf0e..cb8b5b5ea 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -129,6 +129,32 @@ CPython's C-function argument parsing, does *not* implicitly coerce an instead accepts anything with `__float__`/`__index__`, matching how a script author actually writes coordinates.) +### Layers: `ocs.active_document.layers` + +The layer table, with every change validated by the host, recorded as one undo +step, and refused (a `RuntimeError`, nothing changed) when it cannot be honoured. + +```python +L = ocs.active_document.layers +L.create("Walls", color=1, lineweight=50, description="load bearing") +L.create("Grid", color=(10, 200, 30), linetype="Continuous", off=True) +L.modify("Walls", color=5, locked=True) # only the properties given +L.rename("Walls", "Structure") # entities on it follow +L.set_current("Grid") +L.delete("Structure", erase_objects=True) # refused if it holds objects otherwise +"Grid" in L, L["Grid"]["color"], L.current, L.names() +``` + +Properties: `color` (ACI 1-255, an `(r, g, b)` tuple or a Color dict), `linetype` +(must exist in the drawing), `lineweight` (1/100 mm, 0-211; -1 ByLayer, -2 ByBlock, +-3 Default), `off`, `frozen`, `locked`, `plottable`, `transparency` (percent 0-90), +`description`. Records also carry `handle`, `entity_count` and `current`. +Refused: an existing or invalid name (empty, over 255 characters, or containing +`<>/\":;?*|=` or a backquote), a color of ByLayer/ByBlock/0, an unknown property, +renaming or deleting layer `0` or `Defpoints`, deleting or freezing the current +layer, deleting an externally referenced layer, and modifying with no properties. +Making a layer current is a setting, not an undo step. + ### Historical command-replay experiment (not in the default build) The following records an earlier experiment. The bundled build does **not** diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index ec5fd8ab1..b14708080 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -199,9 +199,91 @@ class _Solids: return self.transform(entity, ocs.mirror_matrix(index, [float(v) for v in about])) +class _Layers: + """The drawing's layer table. Every change goes through the host: it is + validated, becomes one undo step, and a refusal raises `RuntimeError` + without changing the drawing.""" + + _KEYS = ("color", "linetype", "lineweight", "off", "frozen", "locked", + "plottable", "transparency", "description") + + def _records(self): + return ocs.layer_records() + + def __iter__(self): + return iter(self._records()) + + def __len__(self): + return len(self._records()) + + def __contains__(self, name): + wanted = str(name).strip().upper() + return any(r["name"].upper() == wanted for r in self._records()) + + def __getitem__(self, name): + wanted = str(name).strip().upper() + for record in self._records(): + if record["name"].upper() == wanted: + return record + raise KeyError(name) + + def get(self, name, default=None): + try: + return self[name] + except KeyError: + return default + + def names(self): + return [r["name"] for r in self._records()] + + @property + def current(self): + for record in self._records(): + if record["current"]: + return record + return None + + @current.setter + def current(self, name): + self.set_current(name) + + def _options(self, properties): + unknown = [k for k in properties if k not in self._KEYS] + if unknown: + raise TypeError("unknown layer propert%s: %s" % ("y" if len(unknown) == 1 else "ies", ", ".join(sorted(unknown)))) + return {k: v for k, v in properties.items() if v is not None} + + def create(self, name, **properties): + """Create a layer and return its record. `color` is an ACI index + (1-255), an (r, g, b) tuple or a Color dict; `lineweight` is in + 1/100 mm (-1 ByLayer, -2 ByBlock, -3 Default); `transparency` is a + percentage 0-90.""" + ocs.layer_operation("create", str(name), self._options(properties)) + return self[name] + + def modify(self, name, **properties): + """Change only the properties given and return the updated record.""" + ocs.layer_operation("modify", str(name), self._options(properties)) + return self[name] + + def rename(self, name, new_name): + ocs.layer_operation("rename", str(name), {"to": str(new_name)}) + return self[new_name] + + def delete(self, name, erase_objects=False): + """Delete a layer. A layer that still holds objects is refused unless + `erase_objects=True`. Layer 0, Defpoints and the current layer stay.""" + ocs.layer_operation("delete", str(name), {"erase_objects": bool(erase_objects)}) + + def set_current(self, name): + ocs.layer_operation("set_current", str(name), None) + return self[name] + + class _Document: def __init__(self): self._pending = None + self.layers = _Layers() self.entities = _Entities(self) self.solids = _Solids(self) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index e4c9ce43c..65d9c1ad2 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -952,6 +952,165 @@ mod ocs { Ok(vm.ctx.new_list(output).into()) } + fn layer_options( + name: String, + options: &rustpython_vm::builtins::PyDictRef, + vm: &VirtualMachine, + ) -> PyResult { + use acadrust::types::{Color, LineWeight, Transparency}; + ensure_known_entity_keys( + options, + "layer", + &["color", "linetype", "lineweight", "off", "frozen", "locked", "plottable", "transparency", "description"], + vm, + )?; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let mut config = ocs_plugin_api::host::LayerConfig { name, ..Default::default() }; + if let Some(value) = present("color")? { + config.color = Some(if let Ok(index) = value.clone().try_into_value::(vm) { + let index = u8::try_from(index) + .ok() + .filter(|i| *i != 0) + .ok_or_else(|| vm.new_value_error("ocs: a layer color index must be 1..=255".to_owned()))?; + Color::Index(index) + } else if let Ok(rgb) = value.clone().try_into_value::>(vm) { + let byte = |v: i64| { + u8::try_from(v).map_err(|_| vm.new_value_error("ocs: RGB components must be 0..=255".to_owned())) + }; + match rgb.as_slice() { + [r, g, b] => Color::Rgb { r: byte(*r)?, g: byte(*g)?, b: byte(*b)? }, + _ => return Err(vm.new_value_error("ocs: an RGB color needs three numbers".to_owned())), + } + } else { + py_to_color_dict(value, vm)? + }); + } + if let Some(value) = present("linetype")? { + config.linetype = Some(value.try_into_value::(vm)?); + } + if let Some(value) = present("lineweight")? { + let weight = value.try_into_value::(vm)?; + let weight = i16::try_from(weight) + .map_err(|_| vm.new_value_error("ocs: lineweight is out of range".to_owned()))?; + config.lineweight = Some(LineWeight::from_value(weight)); + } + for (key, slot) in [ + ("off", &mut config.off), + ("frozen", &mut config.frozen), + ("locked", &mut config.locked), + ("plottable", &mut config.plottable), + ] { + if let Some(value) = present(key)? { + *slot = Some(value.try_into_value::(vm)?); + } + } + if let Some(value) = present("transparency")? { + let percent = py_number_to_f64(value, vm)?; + if !(0.0..=90.0).contains(&percent) { + return Err(vm.new_value_error("ocs: layer transparency is a percentage from 0 to 90".to_owned())); + } + config.transparency = Some(if percent == 0.0 { Transparency::ByLayer } else { Transparency::from_percent(percent) }); + } + if let Some(value) = present("description")? { + config.description = Some(value.try_into_value::(vm)?); + } + Ok(config) + } + + /// Layer-table change through the host: `op` is `create`, `modify`, + /// `rename` (`options={"to": ...}`), `delete` (`options={"erase_objects": bool}`) + /// or `set_current`. Returns the layer's handle. + #[pyfunction] + fn layer_operation(op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::TableOperation; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let operation = match op.as_str() { + "create" => TableOperation::LayerCreate { config: layer_options(name, &options, vm)? }, + "modify" => TableOperation::LayerModify { config: layer_options(name, &options, vm)? }, + "rename" => { + ensure_known_entity_keys(&options, "layer rename", &["to"], vm)?; + let to = options + .get_item_opt("to", vm)? + .ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))? + .try_into_value::(vm)?; + TableOperation::LayerRename { from: name, to } + } + "delete" => { + ensure_known_entity_keys(&options, "layer delete", &["erase_objects"], vm)?; + let erase_objects = get_opt_bool(&options, "erase_objects", vm)?; + TableOperation::LayerDelete { name, erase_objects } + } + "set_current" => TableOperation::LayerSetCurrent { name }, + other => return Err(vm.new_value_error(format!("ocs.layer_operation: unknown operation {other:?}"))), + }; + let result = host_ctx::with_host(|host| host.table_operation(operation)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.layer_operation: {error}"))) + } + + /// Every layer with its full properties, in table order. + #[pyfunction] + fn layer_records(vm: &VirtualMachine) -> PyResult { + use acadrust::types::{LineWeight, Transparency}; + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let mut counts = std::collections::HashMap::::new(); + for entity in document.entities() { + *counts.entry(entity.common().layer.to_uppercase()).or_default() += 1; + } + let current = document.header.current_layer_name.to_uppercase(); + document + .layers + .iter() + .map(|layer| { + ( + layer.clone(), + counts.get(&layer.name.to_uppercase()).copied().unwrap_or(0), + layer.name.to_uppercase() == current, + ) + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (layer, count, is_current) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(layer.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(layer.name.clone()), vm)?; + dict.set_item("color", color_to_py_dict(vm, &layer.color)?, vm)?; + dict.set_item("linetype", vm.new_pyobj(layer.line_type.clone()), vm)?; + let weight = match layer.line_weight { + LineWeight::ByLayer => -1, + LineWeight::ByBlock => -2, + LineWeight::Default => -3, + LineWeight::Value(v) => i64::from(v), + }; + dict.set_item("lineweight", vm.new_pyobj(weight), vm)?; + dict.set_item("off", vm.new_pyobj(layer.flags.off), vm)?; + dict.set_item("frozen", vm.new_pyobj(layer.flags.frozen), vm)?; + dict.set_item("locked", vm.new_pyobj(layer.flags.locked), vm)?; + dict.set_item("plottable", vm.new_pyobj(layer.is_plottable), vm)?; + let transparency = match layer.transparency { + Transparency::Explicit(_) => layer.transparency.as_percent(), + _ => 0.0, + }; + dict.set_item("transparency", vm.new_pyobj(transparency), vm)?; + dict.set_item("description", vm.new_pyobj(layer.description.clone()), vm)?; + dict.set_item("entity_count", vm.new_pyobj(count), vm)?; + dict.set_item("current", vm.new_pyobj(is_current), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + /// Layer names, visibility flags, and top-level entity counts. #[pyfunction] fn layers(vm: &VirtualMachine) -> PyResult { diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index 59aa8071f..334ad66ec 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -34,6 +34,8 @@ class DocumentModelTests(unittest.TestCase): tab_id=lambda: 7, request_input=lambda prompt, entity: (prompt, entity), poll_input=lambda token: {"status": "point", "point": [1, 2, 3]}, + layer_records=lambda: [{"name": "0", "current": True}, {"name": "Walls", "current": False}], + layer_operation=lambda op, name, options: self.calls.append((op, name, options)) or 9, ) namespace = {"ocs": self.ocs} exec(MODEL.read_text(), namespace) @@ -54,6 +56,22 @@ class DocumentModelTests(unittest.TestCase): raise ValueError() self.assertEqual(len(self.calls), 1) + def test_layers_lookup_and_options(self): + layers = self.doc.layers + self.assertIn("WALLS", layers) + self.assertEqual(layers["walls"]["name"], "Walls") + self.assertEqual(layers.current["name"], "0") + self.assertEqual(layers.names(), ["0", "Walls"]) + with self.assertRaises(KeyError): + layers["Nope"] + layers.modify("Walls", color=3, linetype=None, off=False) + self.assertEqual(self.calls[-1], ("modify", "Walls", {"color": 3, "off": False})) + layers.delete("Walls", erase_objects=1) + self.assertEqual(self.calls[-1], ("delete", "Walls", {"erase_objects": True})) + with self.assertRaises(TypeError): + layers.create("New", colour=1) + self.assertEqual(len(self.calls), 2) + def test_create_and_delete_use_document_model(self): line = self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, end={"x": 10, "y": 0, "z": 0}) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 3e1c19e7d..fc7ab29d0 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -1100,6 +1100,161 @@ impl<'a> HostSession<'a> { Some(handle) } + /// Validated, undoable layer-table operations (API v7, additive). Every + /// refusal happens before the undo step is recorded, so a failure leaves the + /// drawing and its history untouched. + pub fn table_operation( + &mut self, + operation: ocs_plugin_api::host::TableOperation, + ) -> Result { + use ocs_plugin_api::host::TableOperation; + let layer_handle = |doc: &CadDocument, name: &str| -> Result { + doc.layers + .get(name) + .map(|layer| layer.handle) + .ok_or_else(|| format!("layer {name:?} does not exist")) + }; + let protected = |name: &str| { + let name = name.trim(); + name == "0" || name.eq_ignore_ascii_case("Defpoints") + }; + let same_name = |a: &str, b: &str| a.trim().to_uppercase() == b.trim().to_uppercase(); + let check_color = |color: &acadrust::types::Color| match color { + acadrust::types::Color::ByLayer + | acadrust::types::Color::ByBlock + | acadrust::types::Color::None => Err("a layer color must be an index 1-255 or an RGB value".to_owned()), + acadrust::types::Color::Index(0) => Err("a layer color index must be 1-255".to_owned()), + _ => Ok(()), + }; + let check_config = |config: &ocs_plugin_api::host::LayerConfig| -> Result<(), String> { + if let Some(color) = &config.color { + check_color(color)?; + } + if let Some(acadrust::types::LineWeight::Value(value)) = config.lineweight { + if !(0..=211).contains(&value) { + return Err("a layer lineweight must be between 0 and 211 (1/100 mm)".to_owned()); + } + } + Ok(()) + }; + match operation { + TableOperation::LayerCreate { config } => { + let name = validated_layer_name(&config.name)?; + if self.document().layers.contains(&name) { + return Err(format!("layer {name:?} already exists")); + } + check_config(&config)?; + if let Some(linetype) = &config.linetype { + if resolve_linetype(self.document_mut(), linetype).is_none() { + return Err(format!("linetype {linetype:?} does not exist in this drawing")); + } + } + self.push_undo("Create layer"); + let config = ocs_plugin_api::host::LayerConfig { name, ..config }; + self.add_layer(config).ok_or_else(|| "the layer could not be created".to_owned()) + } + TableOperation::LayerModify { config } => { + let name = config.name.trim().to_owned(); + let handle = layer_handle(self.document(), &name)?; + if config.color.is_none() + && config.linetype.is_none() + && config.lineweight.is_none() + && config.off.is_none() + && config.frozen.is_none() + && config.locked.is_none() + && config.plottable.is_none() + && config.transparency.is_none() + && config.description.is_none() + { + return Err("no layer properties to change".to_owned()); + } + check_config(&config)?; + if config.frozen == Some(true) + && same_name(&self.document().header.current_layer_name, &name) + { + return Err("the current layer cannot be frozen".to_owned()); + } + if let Some(linetype) = &config.linetype { + if resolve_linetype(self.document_mut(), linetype).is_none() { + return Err(format!("linetype {linetype:?} does not exist in this drawing")); + } + } + self.push_undo("Modify layer"); + if self.modify_layer(config) { + Ok(handle) + } else { + Err("the layer could not be changed".to_owned()) + } + } + TableOperation::LayerRename { from, to } => { + let handle = layer_handle(self.document(), from.trim())?; + if protected(&from) { + return Err(format!("layer {:?} cannot be renamed", from.trim())); + } + let to = validated_layer_name(&to)?; + if from.trim() == to { + return Err("the new layer name is the same as the current one".to_owned()); + } + if !same_name(&from, &to) && self.document().layers.contains(&to) { + return Err(format!("layer {to:?} already exists")); + } + self.push_undo("Rename layer"); + if !self.app.tabs[self.tab].rename_layer(from.trim(), &to) { + return Err("the layer could not be renamed".to_owned()); + } + self.finish_layer_change(&[from.trim().to_owned(), to]); + Ok(handle) + } + TableOperation::LayerDelete { name, erase_objects } => { + let name = name.trim().to_owned(); + let handle = layer_handle(self.document(), &name)?; + if protected(&name) { + return Err(format!("layer {name:?} cannot be deleted")); + } + if same_name(&self.document().header.current_layer_name, &name) { + return Err("the current layer cannot be deleted".to_owned()); + } + if name.contains('|') { + return Err("an externally referenced layer cannot be deleted".to_owned()); + } + let key = name.to_uppercase(); + let on_layer: Vec = self + .document() + .entities() + .filter(|entity| entity.common().layer.to_uppercase() == key) + .map(|entity| entity.common().handle) + .collect(); + if !on_layer.is_empty() && !erase_objects { + return Err(format!( + "layer {name:?} still holds {} object(s); pass erase_objects=True to erase them with the layer", + on_layer.len() + )); + } + self.push_undo("Delete layer"); + self.document_mut().layers.remove(&name); + if !on_layer.is_empty() { + self.app.tabs[self.tab].scene.erase_entities(&on_layer); + } + self.finish_layer_change(&[name]); + Ok(handle) + } + TableOperation::LayerSetCurrent { name } => { + let name = name.trim().to_owned(); + let handle = layer_handle(self.document(), &name)?; + let stored = self.document().layers.get(&name).map(|l| l.name.clone()).unwrap_or(name); + self.app.set_current_layer_name(self.tab, &stored)?; + Ok(handle) + } + } + } + + fn finish_layer_change(&mut self, names: &[String]) { + self.app.tabs[self.tab].dirty = true; + self.app.tabs[self.tab].scene.invalidate_layer_dependencies(names); + self.app.refresh_layer_panel(); + self.publish_document_view(); + } + pub fn modify_layer(&mut self, config: ocs_plugin_api::host::LayerConfig) -> bool { let trimmed = config.name.trim(); if trimmed.is_empty() { @@ -1163,6 +1318,21 @@ impl<'a> HostSession<'a> { } } +/// A layer name AutoCAD would accept: 1-255 characters, none of `<>/\":;?*|=\``. +fn validated_layer_name(raw: &str) -> Result { + let name = raw.trim(); + if name.is_empty() { + return Err("a layer name cannot be empty".to_owned()); + } + if name.chars().count() > 255 { + return Err("a layer name is limited to 255 characters".to_owned()); + } + if let Some(bad) = name.chars().find(|c| "<>/\\\":;?*|=`".contains(*c) || c.is_control()) { + return Err(format!("a layer name cannot contain {bad:?}")); + } + Ok(name.to_owned()) +} + /// The stored spelling of `name` in the drawing's linetype table, loading the /// standard linetypes first when it is not there yet. `None` when the drawing /// cannot supply it: a layer must not reference a linetype that does not exist. @@ -1310,6 +1480,9 @@ impl HostApi for HostSession<'_> { fn modify_layer(&mut self, config: ocs_plugin_api::host::LayerConfig) -> bool { self.modify_layer(config) } + fn table_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { + self.table_operation(operation) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) @@ -6092,6 +6265,133 @@ mod tests { } } + /// Layer table through Python over the real runner: create, modify, rename, + /// delete and make current, with refusals that leave the drawing untouched, + /// one undo step per change, and DWG/DXF persistence of every property. + #[test] + fn audit_python_layer_table_over_real_ipc() { + use acadrust::types::{Color, LineWeight}; + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let mut run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_layers_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\n", + "doc = ocs.active_document\nL = doc.layers\n", + "refused, accepted = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n accepted.append(tag)\n", + "try:\n{body}\n", + "except Exception as error:\n", + " accepted.append('SCRIPTERROR_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:120])\n", + "L.create('REPORT ' + ' '.join(refused) + ' ~ ' + ' '.join(accepted))\n", + ), body = body.lines().map(|line| format!(" {line}")).collect::>().join("\n"))).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (Vec, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, accepted) = name["REPORT".len()..].split_once('~').unwrap(); + let words = |s: &str| s.split_whitespace().map(str::to_owned).collect::>(); + (words(refused), words(accepted)) + }; + + // Build: two layers with full properties, a modify, an entity, a rename, and the refusals. + run(&mut host, "build", r#" +L.create('Walls', color=1, lineweight=50, description='load bearing', transparency=30) +L.create('Grid', color=(10, 200, 30), linetype='Continuous', off=True, plottable=False) +L.modify('Walls', color=5, locked=True) +doc.create_entity('Line', start=P(0, 0, 0), end=P(1, 0, 0), layer='Walls') +L.rename('Walls', 'Structure') +check('dup', lambda: L.create('structure')) +check('badname', lambda: L.create('a = document.layers.iter().map(|l| l.name.as_str()).collect(); + assert!(!names.iter().any(|n| n.eq_ignore_ascii_case("Walls")), "{label}: old name gone: {names:?}"); + assert!(!names.iter().any(|n| n.starts_with('C') && n.len() == 2), "{label}: a refused request left a layer: {names:?}"); + let structure = document.layers.get("Structure").unwrap_or_else(|| panic!("{label}: Structure missing")); + assert_eq!(structure.color, Color::Index(5), "{label}"); + assert_eq!(structure.line_weight, LineWeight::Value(50), "{label}"); + assert!(structure.flags.locked, "{label}"); + assert_eq!(structure.description, "load bearing", "{label}"); + assert_ne!(structure.transparency, acadrust::types::Transparency::ByLayer, "{label}: transparency"); + let grid = document.layers.get("Grid").unwrap_or_else(|| panic!("{label}: Grid missing")); + assert_eq!(grid.color, Color::Rgb { r: 10, g: 200, b: 30 }, "{label}"); + assert!(grid.flags.off && !grid.is_plottable, "{label}"); + assert_eq!(grid.line_type.to_uppercase(), "CONTINUOUS", "{label}"); + let line = document.entities().find_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }) + .unwrap_or_else(|| panic!("{label}: line missing")); + assert_eq!(line.common.layer, "Structure", "{label}: entity followed the rename"); + }; + check_state("live", host.document()); + let dwg = crate::io::load_bytes("layers.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("layers.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + // Destroy: current layer, delete with and without objects. + run(&mut host, "destroy", r#" +L.set_current('Grid') +check('delcurrent2', lambda: L.delete('Grid')) +check('freezecurrent2', lambda: L.modify('Grid', frozen=True)) +L.set_current('0') +L.delete('Grid') +check('delused2', lambda: L.delete('Structure')) +L.delete('Structure', erase_objects=True) +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "{accepted:?}"); + assert_eq!(refused, vec!["delcurrent2", "freezecurrent2", "delused2"]); + let document = host.document(); + assert!(document.layers.get("Grid").is_none() && document.layers.get("Structure").is_none()); + assert_eq!(document.header.current_layer_name, "0"); + assert!(!document.entities().any(|e| matches!(e, EntityType::Line(_))), "erase_objects erased the line"); + + // U: the last two steps are the report layer and the erasing delete; + // undoing them restores the deleted layer and its object together. + drop(process); + drop(host); + app.finish_pending_history(0); + let before = app.tabs[0].history.undo_stack.len(); + app.undo_steps(2); + assert_eq!(app.tabs[0].history.undo_stack.len(), before - 2); + let document = &app.tabs[0].scene.document; + assert!(document.layers.get("Structure").is_some(), "undo restored the layer"); + assert!(document.entities().any(|e| matches!(e, EntityType::Line(_))), "undo restored the line"); + } + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType; From aaea15e4efe68975e48529b5b2a0b04aa77a3efa Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 16:18:22 +0200 Subject: [PATCH 51/76] feat(python): text and dimension styles (doc.text_styles, doc.dim_styles) Extends TableOperation with text style create/modify, dimension style create/modify (DimStyle fields as a validated JSON object), rename, delete and set-current. Reuses OCS's style in-use scan and reference-following rename (four style_ops methods widened to pub(super)); a renamed text style also updates dimension styles that name it. Real-IPC audit with DWG/DXF persistence and undo, IPC and Python unit tests, docs. Four cadcodec findings are pinned by canaries. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 43 ++ crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 9 + docs/cadcodec-reader-gaps.md | 14 + docs/plugin-host-model-completion-plan.md | 13 + plugins/opencad-python/PLUGIN.md | 30 ++ plugins/opencad-python/src/document_model.py | 118 +++++ plugins/opencad-python/src/ocs_module.rs | 215 +++++++++ .../tests/test_document_model.py | 22 + src/app/mod.rs | 2 +- src/app/plugin_host.rs | 428 +++++++++++++++++- src/app/style_ops.rs | 8 +- 11 files changed, 894 insertions(+), 8 deletions(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 029a43ec0..3dc1ebf6d 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -102,6 +102,49 @@ pub enum TableOperation { 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, 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 }, +} + +/// 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, + pub width_factor: Option, + pub oblique_angle: Option, + pub font_file: Option, + pub big_font_file: Option, + pub true_type_font: Option, + pub backward: Option, + pub upside_down: Option, + pub vertical: Option, + pub annotative: Option, } /// A kernel operation on ACIS-backed solids (API v7, additive). The host owns diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index fd8a65cff..1960aa9e0 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -122,6 +122,15 @@ mod tests { 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() }, ] { let bytes = bincode::serialize(&PluginRequest::TableOperation { operation: operation.clone() }).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), diff --git a/docs/cadcodec-reader-gaps.md b/docs/cadcodec-reader-gaps.md index 9dbe422c4..47e35e62d 100644 --- a/docs/cadcodec-reader-gaps.md +++ b/docs/cadcodec-reader-gaps.md @@ -195,3 +195,17 @@ These are format constraints or OCS-side issues, not reader bugs: - **Spline weights in DXF** were briefly suspected but are correct: the writer emits group 41 only when `flags.rational` is set, so the host must set that flag when weights are present (fixed in OCS). + +### Text and dimension style findings (2026-09-21, not filed) + +Found by `audit_python_text_and_dim_styles_over_real_ipc`; each is pinned by a canary. + +- **`STYLE` generation flags:** the DXF writer hard-codes group 71 to 0, so a text + style's `flags.backward` and `flags.upside_down` are lost on every DXF save (DWG keeps them). +- **`STYLE` oblique angle units:** group 50 is written and read as the raw radian value, while DXF + defines it in degrees; it round-trips inside cadcodec but AutoCAD would read 15 degrees as 0.26. + DWG stores radians correctly. +- **`DIMSTYLE` text style name:** the DXF reader keeps only the text-style handle (group 340) and never + resolves `dimtxsty` from it, so the name reopens as `Standard` (the handle is right; DWG resolves both). +- **`TextStyle::true_type_font`** is never written or read by either codec, so it lives only in memory. + diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index a7f813499..1db480afc 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -200,3 +200,16 @@ nothing changes. Python: `ocs.active_document.layers` (`create`, `modify`, `rena (19 refusals, live/DWG/DXF persistence of colour, lineweight, transparency, flags, linetype, description, rename following entities, `erase_objects` and one-step undo), an IPC round-trip test and a Python unit test. Next: text and dimension styles, linetypes, blocks, layouts; then headless commands. + +### 2026-09-21: text and dimension styles (`doc.text_styles`, `doc.dim_styles`) + +`TableOperation` grew `TextStyleCreate/Modify`, `DimStyleCreate/Modify` (DimStyle fields travel as a JSON +object, so all 88 are settable with host validation; handles, xref fields and the name are refused), +`StyleRename`, `StyleDelete`, `StyleSetCurrent`. The host reuses OCS's own style machinery (in-use scan, +reference-following rename) by widening four `style_ops` methods to `pub(super)`, and additionally makes a +renamed text style's name follow into dimension styles. Style operations require the active tab. +Evidence: `audit_python_text_and_dim_styles_over_real_ipc` (26 refusals, live/DWG/DXF persistence, copy +semantics, rename following references, current/delete guards, five-step undo), IPC round-trip and Python unit +tests. Four cadcodec findings (STYLE flags and oblique units, DIMSTYLE name, `true_type_font`) are pinned by +canaries and listed in `cadcodec-reader-gaps.md`. Next: linetypes, blocks, layouts; then headless commands. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index cb8b5b5ea..49a2f4b75 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -155,6 +155,36 @@ renaming or deleting layer `0` or `Defpoints`, deleting or freezing the current layer, deleting an externally referenced layer, and modifying with no properties. Making a layer current is a setting, not an undo step. +### Text and dimension styles: `doc.text_styles`, `doc.dim_styles` + +```python +S, D = ocs.active_document.text_styles, ocs.active_document.dim_styles +S.create("Title", height=5, width_factor=0.8, oblique=15, font="romans", annotative=True) +S.modify("Title", height=6) +D.create("Metric", dimscale=2, dimtxt=3.5, dimtxsty="Title") # DimStyle field names +D.create("Metric2", copy_from="Metric", dimtxt=4) +S.rename("Title", "Heading") # entities, dimension styles and tables follow +S.set_current("Heading"); D.set_current("Metric") +D.delete("Metric2"); S["Heading"]["height"]; D.current["name"]; S.names() +``` + +Same guarantees as layers: host-validated, one undo step, a refusal raises +`RuntimeError` and changes nothing (making a style current is a setting). +Text properties: `height` (0 = each text chooses), `width_factor` (0-100), +`oblique` (degrees, within 85), `font`, `big_font`, `backward`, `upside_down`, +`vertical`, `annotative`; use `font="arial.ttf"` for a TrueType face, because +the codec does not persist a separate family name. Dimension styles use the +`DimStyle` field names (`dimscale`, `dimtxt`, `dimasz`, ...); records list all +of them. Handles, `xref_*` fields and `name` are host-managed and refused, unknown +fields and wrongly typed values are refused, `dimscale` and `dimtxt` must be +positive, and `dimtxsty` must name an existing text style. `Standard` is never +renamed or deleted; a style that is current or still referenced is never deleted; +a case-only rename is refused. Style operations act on the active document. + +Known persistence limits (cadcodec, see `docs/cadcodec-reader-gaps.md`): a DXF +save drops the backward/upside-down flags of a text style and does not restore a +dimension style's `dimtxsty` name (the handle link survives); DWG keeps both. + ### Historical command-replay experiment (not in the default build) The following records an earlier experiment. The bundled build does **not** diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index b14708080..61dfa2fa9 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -280,10 +280,128 @@ class _Layers: return self[name] +class _Styles: + """Common lookup, rename, delete and current-style handling for the text + and dimension style tables. Every change is validated by the host, is one + undo step (making a style current is a setting), and a refusal raises + `RuntimeError` without changing the drawing. `Standard` is never renamed + or deleted, and a style that is current or in use is never deleted.""" + + _KIND = None + _RECORDS = None + + def _records(self): + return getattr(ocs, self._RECORDS)() + + def __iter__(self): + return iter(self._records()) + + def __len__(self): + return len(self._records()) + + def __contains__(self, name): + wanted = str(name).strip().upper() + return any(r["name"].upper() == wanted for r in self._records()) + + def __getitem__(self, name): + wanted = str(name).strip().upper() + for record in self._records(): + if record["name"].upper() == wanted: + return record + raise KeyError(name) + + def get(self, name, default=None): + try: + return self[name] + except KeyError: + return default + + def names(self): + return [r["name"] for r in self._records()] + + @property + def current(self): + for record in self._records(): + if record["current"]: + return record + return None + + @current.setter + def current(self, name): + self.set_current(name) + + def _run(self, op, name, options=None): + ocs.style_operation(self._KIND, op, str(name), options) + + def rename(self, name, new_name): + self._run("rename", name, {"to": str(new_name)}) + return self[new_name] + + def delete(self, name): + self._run("delete", name) + + def set_current(self, name): + self._run("set_current", name) + return self[name] + + +class _TextStyles(_Styles): + """`ocs.active_document.text_styles`. Properties: `height` (0 = each text + chooses), `width_factor`, `oblique` (degrees, within 85), `font` (SHX file), + `big_font`, `backward`, `upside_down`, `vertical`, `annotative`. Give a + TrueType face as a font file name such as `font="arial.ttf"`: the codec does + not persist a separate TrueType family name, so it is not exposed.""" + + _KIND = "text" + _RECORDS = "text_style_records" + _KEYS = ("height", "width_factor", "oblique", "font", "big_font", + "backward", "upside_down", "vertical", "annotative") + + def _options(self, properties): + unknown = [k for k in properties if k not in self._KEYS] + if unknown: + raise TypeError("unknown text style propert%s: %s" % ("y" if len(unknown) == 1 else "ies", ", ".join(sorted(unknown)))) + return {k: v for k, v in properties.items() if v is not None} + + def create(self, name, **properties): + self._run("create", name, self._options(properties)) + return self[name] + + def modify(self, name, **properties): + self._run("modify", name, self._options(properties)) + return self[name] + + +class _DimStyles(_Styles): + """`ocs.active_document.dim_styles`. Records and properties use the DimStyle + field names (`dimscale`, `dimtxt`, `dimasz`, `dimtxsty` ...); handles and + xref fields are host-managed and refused. `create(..., copy_from="Other")` + starts from an existing style.""" + + _KIND = "dim" + _RECORDS = "dim_style_records" + + def _options(self, properties): + return {k: v for k, v in properties.items() if v is not None or k == "dimclrd_true_color"} + + def create(self, name, copy_from=None, **properties): + options = self._options(properties) + if copy_from is not None: + options["copy_from"] = str(copy_from) + self._run("create", name, options) + return self[name] + + def modify(self, name, **properties): + self._run("modify", name, self._options(properties)) + return self[name] + + class _Document: def __init__(self): self._pending = None self.layers = _Layers() + self.text_styles = _TextStyles() + self.dim_styles = _DimStyles() self.entities = _Entities(self) self.solids = _Solids(self) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 65d9c1ad2..42c5804d3 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -952,6 +952,7 @@ mod ocs { Ok(vm.ctx.new_list(output).into()) } + #[cfg(feature = "experimental-host-model")] fn layer_options( name: String, options: &rustpython_vm::builtins::PyDictRef, @@ -1022,6 +1023,7 @@ mod ocs { /// Layer-table change through the host: `op` is `create`, `modify`, /// `rename` (`options={"to": ...}`), `delete` (`options={"erase_objects": bool}`) /// or `set_current`. Returns the layer's handle. + #[cfg(feature = "experimental-host-model")] #[pyfunction] fn layer_operation(op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { use ocs_plugin_api::host::TableOperation; @@ -1056,7 +1058,220 @@ mod ocs { .map_err(|error| vm.new_runtime_error(format!("ocs.layer_operation: {error}"))) } + /// Convert a Python value (None, bool, int, float, str, list/tuple, dict + /// with string keys) to JSON. + #[cfg(feature = "experimental-host-model")] + fn py_to_json(value: &PyObjectRef, vm: &VirtualMachine) -> PyResult { + use rustpython_vm::builtins::{PyDict, PyFloat, PyInt, PyList, PyStr, PyTuple}; + use rustpython_vm::AsObject; + if vm.is_none(value) { + return Ok(serde_json::Value::Null); + } + if let Ok(flag) = value.clone().try_into_value::(vm) { + if value.class().is(vm.ctx.types.bool_type) { + return Ok(serde_json::Value::Bool(flag)); + } + } + if value.downcast_ref::().is_some() { + return Ok(serde_json::Value::from(value.clone().try_into_value::(vm)?)); + } + if value.downcast_ref::().is_some() { + let number = value.clone().try_into_value::(vm)?; + return serde_json::Number::from_f64(number) + .map(serde_json::Value::Number) + .ok_or_else(|| vm.new_value_error("ocs: numbers must be finite".to_owned())); + } + if value.downcast_ref::().is_some() { + return Ok(serde_json::Value::String(value.clone().try_into_value::(vm)?)); + } + if let Some(list) = value.downcast_ref::() { + let items: Vec = list.borrow_vec().to_vec(); + return items.iter().map(|item| py_to_json(item, vm)).collect::>>().map(serde_json::Value::Array); + } + if let Some(tuple) = value.downcast_ref::() { + return tuple.iter().map(|item| py_to_json(item, vm)).collect::>>().map(serde_json::Value::Array); + } + if let Some(dict) = value.downcast_ref::() { + let mut map = serde_json::Map::new(); + for key in dict.keys_vec() { + let name = key.clone().try_into_value::(vm)?; + let item = dict.get_item(&*key, vm)?; + map.insert(name, py_to_json(&item, vm)?); + } + return Ok(serde_json::Value::Object(map)); + } + Err(vm.new_type_error("ocs: unsupported value in a style property".to_owned())) + } + + #[cfg(feature = "experimental-host-model")] + fn style_kind(kind: &str, vm: &VirtualMachine) -> PyResult { + match kind { + "text" => Ok(ocs_plugin_api::host::TableStyleKind::Text), + "dim" => Ok(ocs_plugin_api::host::TableStyleKind::Dim), + other => Err(vm.new_value_error(format!("ocs.style_operation: unknown style kind {other:?}"))), + } + } + + /// Text/dimension style change through the host. `kind` is `text` or `dim`; + /// `op` is `create`, `modify`, `rename` (`{"to": ...}`), `delete` or + /// `set_current`. Text options: `height`, `width_factor`, `oblique` + /// (degrees), `font`, `big_font`, `backward`, + /// `upside_down`, `vertical`, `annotative`. Dimension options are DimStyle + /// field names (plus `copy_from` on create). Returns the style's handle. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn style_operation(kind: String, op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::{TableOperation, TextStyleConfig}; + let style_kind = style_kind(&kind, vm)?; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let operation = match (op.as_str(), kind.as_str()) { + ("rename", _) => { + ensure_known_entity_keys(&options, "style rename", &["to"], vm)?; + let to = options + .get_item_opt("to", vm)? + .ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))? + .try_into_value::(vm)?; + TableOperation::StyleRename { kind: style_kind, from: name, to } + } + ("delete", _) => { + ensure_known_entity_keys(&options, "style delete", &[], vm)?; + TableOperation::StyleDelete { kind: style_kind, name } + } + ("set_current", _) => TableOperation::StyleSetCurrent { kind: style_kind, name }, + ("create" | "modify", "text") => { + ensure_known_entity_keys( + &options, + "text style", + &["height", "width_factor", "oblique", "font", "big_font", "backward", "upside_down", "vertical", "annotative"], + vm, + )?; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let number = |key: &str| -> PyResult> { + present(key)?.map(|v| py_number_to_f64(v, vm)).transpose() + }; + let text = |key: &str| -> PyResult> { + present(key)?.map(|v| v.try_into_value::(vm)).transpose() + }; + let flag = |key: &str| -> PyResult> { + present(key)?.map(|v| v.try_into_value::(vm)).transpose() + }; + let config = TextStyleConfig { + name, + height: number("height")?, + width_factor: number("width_factor")?, + oblique_angle: number("oblique")?.map(f64::to_radians), + font_file: text("font")?, + big_font_file: text("big_font")?, + backward: flag("backward")?, + upside_down: flag("upside_down")?, + vertical: flag("vertical")?, + annotative: flag("annotative")?, + ..Default::default() + }; + if op == "create" { + TableOperation::TextStyleCreate { config } + } else { + TableOperation::TextStyleModify { config } + } + } + ("create" | "modify", "dim") => { + let copy_from = if op == "create" { + options.get_item_opt("copy_from", vm)?.filter(|v| !vm.is_none(v)) + .map(|v| v.try_into_value::(vm)).transpose()? + } else { + None + }; + let properties = vm.ctx.new_dict(); + for key in options.keys_vec() { + if key.clone().try_into_value::(vm)? != "copy_from" { + properties.set_item(&*key, options.get_item(&*key, vm)?, vm)?; + } + } + let properties = py_to_json(&PyObjectRef::from(properties), vm)?.to_string(); + if op == "create" { + TableOperation::DimStyleCreate { name, copy_from, properties } + } else { + TableOperation::DimStyleModify { name, properties } + } + } + (other, _) => return Err(vm.new_value_error(format!("ocs.style_operation: unknown operation {other:?}"))), + }; + let result = host_ctx::with_host(|host| host.table_operation(operation)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.style_operation: {error}"))) + } + + /// Every text style with its properties (`oblique` in degrees). + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn text_style_records(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let current = document.header.current_text_style_name.to_uppercase(); + document + .text_styles + .iter() + .map(|style| (style.clone(), style.name.to_uppercase() == current)) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (style, is_current) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(style.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(style.name.clone()), vm)?; + dict.set_item("height", vm.new_pyobj(style.height), vm)?; + dict.set_item("width_factor", vm.new_pyobj(style.width_factor), vm)?; + dict.set_item("oblique", vm.new_pyobj(style.oblique_angle.to_degrees()), vm)?; + dict.set_item("font", vm.new_pyobj(style.font_file.clone()), vm)?; + dict.set_item("big_font", vm.new_pyobj(style.big_font_file.clone()), vm)?; + dict.set_item("backward", vm.new_pyobj(style.flags.backward), vm)?; + dict.set_item("upside_down", vm.new_pyobj(style.flags.upside_down), vm)?; + dict.set_item("vertical", vm.new_pyobj(style.is_vertical), vm)?; + dict.set_item("annotative", vm.new_pyobj(style.annotative), vm)?; + dict.set_item("current", vm.new_pyobj(is_current), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Every dimension style with all of its DimStyle fields plus `current`. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn dim_style_records(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let current = document.header.current_dimstyle_name.to_uppercase(); + document + .dim_styles + .iter() + .map(|style| { + let mut value = serde_json::to_value(style).unwrap_or(serde_json::Value::Null); + if let Some(object) = value.as_object_mut() { + object.insert("current".to_owned(), (style.name.to_uppercase() == current).into()); + } + value + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let output = rows + .into_iter() + .map(|value| snapshot_value_to_py(value, vm)) + .collect::>>()?; + Ok(vm.ctx.new_list(output).into()) + } + /// Every layer with its full properties, in table order. + #[cfg(feature = "experimental-host-model")] #[pyfunction] fn layer_records(vm: &VirtualMachine) -> PyResult { use acadrust::types::{LineWeight, Transparency}; diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index 334ad66ec..cdd7dd999 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -36,6 +36,9 @@ class DocumentModelTests(unittest.TestCase): poll_input=lambda token: {"status": "point", "point": [1, 2, 3]}, layer_records=lambda: [{"name": "0", "current": True}, {"name": "Walls", "current": False}], layer_operation=lambda op, name, options: self.calls.append((op, name, options)) or 9, + text_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Title", "Notes")], + dim_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Metric")], + style_operation=lambda kind, op, name, options: self.calls.append((kind, op, name, options)) or 9, ) namespace = {"ocs": self.ocs} exec(MODEL.read_text(), namespace) @@ -72,6 +75,25 @@ class DocumentModelTests(unittest.TestCase): layers.create("New", colour=1) self.assertEqual(len(self.calls), 2) + def test_text_and_dim_styles(self): + text, dim = self.doc.text_styles, self.doc.dim_styles + self.assertEqual(text.current["name"], "Standard") + self.assertEqual(text["TITLE"]["name"], "Title") + text.create("Notes", height=2.5, font=None, oblique=10) + self.assertEqual(self.calls[-1], ("text", "create", "Notes", {"height": 2.5, "oblique": 10})) + with self.assertRaises(TypeError): + text.create("Bad", true_type_font="Arial") + with self.assertRaises(TypeError): + text.modify("Title", fnt="x") + dim.create("Metric", copy_from="Standard", dimscale=2) + self.assertEqual(self.calls[-1], ("dim", "create", "Metric", {"dimscale": 2, "copy_from": "Standard"})) + dim.modify("Standard", dimtxt=3) + self.assertEqual(self.calls[-1], ("dim", "modify", "Standard", {"dimtxt": 3})) + dim.delete("Metric") + self.assertEqual(self.calls[-1], ("dim", "delete", "Metric", None)) + dim.set_current("Standard") + self.assertEqual(self.calls[-1], ("dim", "set_current", "Standard", None)) + def test_create_and_delete_use_document_model(self): line = self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, end={"x": 10, "y": 0, "z": 0}) diff --git a/src/app/mod.rs b/src/app/mod.rs index 6714e04df..16b77c3cb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -35,7 +35,7 @@ mod record_api; pub(crate) mod settings; mod shortcuts; mod startup; -mod style_ops; +pub(crate) mod style_ops; mod text_inline; mod tolerance_dialog; mod update; diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index fc7ab29d0..163d6d43d 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -1245,9 +1245,170 @@ impl<'a> HostSession<'a> { self.app.set_current_layer_name(self.tab, &stored)?; Ok(handle) } + other => self.style_operation(other), } } + /// Text and dimension style operations. They act on the active document + /// because the shared style machinery (in-use scan, rename, ribbon sync) is + /// keyed by the active tab. + fn style_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { + use crate::app::style_ops::StyleKind; + use ocs_plugin_api::host::{TableOperation, TableStyleKind}; + if self.tab != self.app.active_tab { + return Err("style operations act on the active document; switch to it first".to_owned()); + } + let kind_of = |kind: TableStyleKind| match kind { + TableStyleKind::Text => StyleKind::Text, + TableStyleKind::Dim => StyleKind::Dim, + }; + let stored = |doc: &CadDocument, kind: TableStyleKind, name: &str| -> Result<(String, Handle), String> { + let found = match kind { + TableStyleKind::Text => doc.text_styles.get(name).map(|s| (s.name.clone(), s.handle)), + TableStyleKind::Dim => doc.dim_styles.get(name).map(|s| (s.name.clone(), s.handle)), + }; + found.ok_or_else(|| format!("{} style {name:?} does not exist", match kind { + TableStyleKind::Text => "text", + TableStyleKind::Dim => "dimension", + })) + }; + match operation { + TableOperation::TextStyleCreate { config } => { + let name = validated_symbol_name(&config.name, "a text style name")?; + if self.app.style_exists(StyleKind::Text, &name) { + return Err(format!("text style {name:?} already exists")); + } + let mut style = acadrust::tables::TextStyle::new(name.clone()); + apply_text_style_config(&mut style, &config)?; + self.push_undo("Create text style"); + let handle = self.document_mut().allocate_handle(); + style.handle = handle; + self.document_mut().text_styles.add(style).map_err(|e| format!("the text style could not be created: {e}"))?; + self.finish_style_change(StyleKind::Text); + Ok(handle) + } + TableOperation::TextStyleModify { config } => { + let (name, handle) = stored(self.document(), TableStyleKind::Text, config.name.trim())?; + let mut style = self.document().text_styles.get(&name).cloned().expect("checked above"); + let before = style.clone(); + if config == (ocs_plugin_api::host::TextStyleConfig { name: config.name.clone(), ..Default::default() }) { + return Err("no text style properties to change".to_owned()); + } + apply_text_style_config(&mut style, &config)?; + if style == before { + return Ok(handle); + } + self.push_undo("Modify text style"); + if let Some(slot) = self.document_mut().text_styles.get_mut(&name) { + *slot = style; + } + self.finish_style_change(StyleKind::Text); + Ok(handle) + } + TableOperation::DimStyleCreate { name, copy_from, properties } => { + let name = validated_symbol_name(&name, "a dimension style name")?; + if self.app.style_exists(StyleKind::Dim, &name) { + return Err(format!("dimension style {name:?} already exists")); + } + let base = match ©_from { + Some(source) => { + let (source, _) = stored(self.document(), TableStyleKind::Dim, source.trim())?; + self.document().dim_styles.get(&source).cloned().expect("checked above") + } + None => acadrust::tables::DimStyle::new(name.clone()), + }; + let mut style = apply_dim_style_json(self.document(), &base, &properties)?; + self.push_undo("Create dimension style"); + let handle = self.document_mut().allocate_handle(); + style.handle = handle; + style.name = name; + self.document_mut().dim_styles.add(style).map_err(|e| format!("the dimension style could not be created: {e}"))?; + self.finish_style_change(StyleKind::Dim); + Ok(handle) + } + TableOperation::DimStyleModify { name, properties } => { + let (name, handle) = stored(self.document(), TableStyleKind::Dim, name.trim())?; + let base = self.document().dim_styles.get(&name).cloned().expect("checked above"); + let style = apply_dim_style_json(self.document(), &base, &properties)?; + if style == base { + return Ok(handle); + } + self.push_undo("Modify dimension style"); + if let Some(slot) = self.document_mut().dim_styles.get_mut(&name) { + *slot = style; + } + self.finish_style_change(StyleKind::Dim); + Ok(handle) + } + TableOperation::StyleRename { kind, from, to } => { + let (from, handle) = stored(self.document(), kind, from.trim())?; + if from.eq_ignore_ascii_case("Standard") { + return Err("the Standard style cannot be renamed".to_owned()); + } + let to = validated_symbol_name(&to, "a style name")?; + if from.eq_ignore_ascii_case(&to) { + return Err("the new style name matches the current one (a case-only change is not supported)".to_owned()); + } + if self.app.style_exists(kind_of(kind), &to) { + return Err(format!("style {to:?} already exists")); + } + self.push_undo("Rename style"); + self.app.rename_style_storage(kind_of(kind), &from, &to); + if kind == TableStyleKind::Text { + // A dimension style names its text style; follow the rename. + for dim in self.document_mut().dim_styles.iter_mut() { + if dim.dimtxsty.eq_ignore_ascii_case(&from) { + dim.dimtxsty = to.clone(); + } + } + } + self.finish_style_change(kind_of(kind)); + Ok(handle) + } + TableOperation::StyleDelete { kind, name } => { + let (name, handle) = stored(self.document(), kind, name.trim())?; + if name.eq_ignore_ascii_case("Standard") { + return Err("the Standard style cannot be deleted".to_owned()); + } + if self.app.style_in_use(kind_of(kind), &name) { + return Err(format!("style {name:?} is current or still in use")); + } + self.push_undo("Delete style"); + if !self.app.remove_style_storage(kind_of(kind), &name) { + return Err("the style could not be deleted".to_owned()); + } + self.finish_style_change(kind_of(kind)); + Ok(handle) + } + TableOperation::StyleSetCurrent { kind, name } => { + let (name, handle) = stored(self.document(), kind, name.trim())?; + let header = &mut self.document_mut().header; + match kind { + TableStyleKind::Text => { + header.current_text_style_handle = handle; + header.current_text_style_name = name.clone(); + self.app.ribbon.active_text_style = name; + } + TableStyleKind::Dim => { + header.current_dimstyle_handle = handle; + header.current_dimstyle_name = name.clone(); + self.app.ribbon.active_dim_style = name; + } + } + self.app.tabs[self.tab].dirty = true; + Ok(handle) + } + _ => Err("unsupported table operation".to_owned()), + } + } + + fn finish_style_change(&mut self, kind: crate::app::style_ops::StyleKind) { + self.app.tabs[self.tab].dirty = true; + self.app.after_style_change(kind); + self.app.tabs[self.tab].scene.bump_geometry(); + self.publish_document_view(); + } + fn finish_layer_change(&mut self, names: &[String]) { self.app.tabs[self.tab].dirty = true; self.app.tabs[self.tab].scene.invalidate_layer_dependencies(names); @@ -1320,19 +1481,125 @@ impl<'a> HostSession<'a> { /// A layer name AutoCAD would accept: 1-255 characters, none of `<>/\":;?*|=\``. fn validated_layer_name(raw: &str) -> Result { + validated_symbol_name(raw, "a layer name") +} + +/// A symbol-table name AutoCAD would accept (layers, styles): 1-255 characters, +/// none of `<>/\":;?*|=` or a backquote, and no control characters. +fn validated_symbol_name(raw: &str, what: &str) -> Result { let name = raw.trim(); if name.is_empty() { - return Err("a layer name cannot be empty".to_owned()); + return Err(format!("{what} cannot be empty")); } if name.chars().count() > 255 { - return Err("a layer name is limited to 255 characters".to_owned()); + return Err(format!("{what} is limited to 255 characters")); } if let Some(bad) = name.chars().find(|c| "<>/\\\":;?*|=`".contains(*c) || c.is_control()) { - return Err(format!("a layer name cannot contain {bad:?}")); + return Err(format!("{what} cannot contain {bad:?}")); } Ok(name.to_owned()) } +fn apply_text_style_config( + style: &mut acadrust::tables::TextStyle, + config: &ocs_plugin_api::host::TextStyleConfig, +) -> Result<(), String> { + let text = |what: &str, value: &str| { + if value.chars().any(|c| c.is_control()) || value.chars().count() > 255 { + Err(format!("{what} must be text of at most 255 characters")) + } else { + Ok(value.trim().to_owned()) + } + }; + if let Some(height) = config.height { + if !height.is_finite() || height < 0.0 { + return Err("text style height must be finite and not negative (0 = variable)".to_owned()); + } + style.height = height; + } + if let Some(width) = config.width_factor { + if !width.is_finite() || width <= 0.0 || width > 100.0 { + return Err("text style width factor must be greater than 0 and at most 100".to_owned()); + } + style.width_factor = width; + } + if let Some(angle) = config.oblique_angle { + if !angle.is_finite() || angle.abs() > 85f64.to_radians() + 1e-9 { + return Err("text style oblique angle must be within 85 degrees either way".to_owned()); + } + style.oblique_angle = angle; + } + if let Some(font) = &config.font_file { + style.font_file = text("font file", font)?; + } + if let Some(font) = &config.big_font_file { + style.big_font_file = text("big font file", font)?; + } + if let Some(font) = &config.true_type_font { + style.true_type_font = text("TrueType font", font)?; + } + if let Some(value) = config.backward { + style.flags.backward = value; + } + if let Some(value) = config.upside_down { + style.flags.upside_down = value; + } + if let Some(value) = config.vertical { + style.is_vertical = value; + } + if let Some(value) = config.annotative { + style.annotative = value; + } + if style.font_file.is_empty() && style.true_type_font.is_empty() { + return Err("a text style needs a font file or a TrueType font".to_owned()); + } + Ok(()) +} + +/// Apply a JSON object of DimStyle fields on top of `base`. Handles and xref +/// fields belong to the host and the name is set separately, so those keys are +/// refused; unknown keys and wrongly typed values are refused too. `dimtxsty` +/// must name an existing text style, whose handle is linked here. +fn apply_dim_style_json( + doc: &CadDocument, + base: &acadrust::tables::DimStyle, + properties: &str, +) -> Result { + let patch: serde_json::Value = serde_json::from_str(properties) + .map_err(|e| format!("dimension style properties are not valid JSON: {e}"))?; + let serde_json::Value::Object(patch) = patch else { + return Err("dimension style properties must be an object".to_owned()); + }; + let mut value = serde_json::to_value(base).map_err(|e| e.to_string())?; + let object = value.as_object_mut().ok_or("dimension style did not serialize to an object")?; + for (key, new) in patch { + if key == "handle" || key == "name" || key.starts_with("xref_") || key.ends_with("_handle") { + return Err(format!("dimension style property {key:?} is managed by the host")); + } + if !object.contains_key(&key) { + return Err(format!("unknown dimension style property {key:?}")); + } + object.insert(key, new); + } + let mut style: acadrust::tables::DimStyle = serde_json::from_value(value) + .map_err(|e| format!("invalid dimension style value: {e}"))?; + if !(style.dimscale.is_finite() && style.dimscale > 0.0) { + return Err("dimscale must be greater than zero".to_owned()); + } + if !(style.dimtxt.is_finite() && style.dimtxt > 0.0) { + return Err("dimtxt (text height) must be greater than zero".to_owned()); + } + if !(style.dimasz.is_finite() && style.dimasz >= 0.0) { + return Err("dimasz (arrow size) cannot be negative".to_owned()); + } + let Some(text_style) = doc.text_styles.get(&style.dimtxsty) else { + return Err(format!("dimtxsty {:?} is not a text style in this drawing", style.dimtxsty)); + }; + style.dimtxsty = text_style.name.clone(); + style.dimtxsty_handle = text_style.handle; + Ok(style) +} + /// The stored spelling of `name` in the drawing's linetype table, loading the /// standard linetypes first when it is not there yet. `None` when the drawing /// cannot supply it: a layer must not reference a linetype that does not exist. @@ -6392,6 +6659,161 @@ L.delete('Structure', erase_objects=True) assert!(document.entities().any(|e| matches!(e, EntityType::Line(_))), "undo restored the line"); } + /// Text and dimension styles through Python over the real runner: create, + /// modify, copy, rename (references follow), delete and make current, with + /// refusals that change nothing, DWG/DXF persistence and one-step undo. + #[test] + fn audit_python_text_and_dim_styles_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_styles_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\nS = doc.text_styles\nD = doc.dim_styles\nL = doc.layers\n", + "refused, accepted = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n accepted.append(tag)\n", + "try:\n{body}\n", + "except Exception as error:\n", + " accepted.append('SCRIPTERROR_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:120])\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(accepted))\n", + ), body = body.lines().map(|line| format!(" {line}")).collect::>().join("\n"))).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, accepted) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), accepted.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +S.create('Title', height=5, width_factor=0.8, oblique=15, font='romans', big_font='bigfont', backward=True, annotative=True) +S.create('Notes', font='arial.ttf') +S.modify('Title', height=6, upside_down=True) +D.create('Metric', dimscale=2, dimtxt=3.5, dimasz=2.5, dimtxsty='Title') +D.create('Metric2', copy_from='Metric', dimtxt=4) +D.modify('Metric', dimscale=3) +S.rename('Notes', 'Remarks') +S.rename('Title', 'Heading') +check('t_dup', lambda: S.create('heading')) +check('t_badname', lambda: S.create('a|b')) +check('t_height', lambda: S.create('T1', height=-1)) +check('t_width0', lambda: S.create('T2', width_factor=0)) +check('t_oblique', lambda: S.create('T3', oblique=89)) +check('t_nofont', lambda: S.create('T4', font='')) +check('t_unknown', lambda: S.create('T5', fnt='x')) +check('t_modmissing', lambda: S.modify('Nope', height=1)) +check('t_modnone', lambda: S.modify('Heading')) +check('t_renameStandard', lambda: S.rename('Standard', 'Std2')) +check('t_rename_case', lambda: S.rename('Heading', 'HEADING')) +check('t_rename_clash', lambda: S.rename('Remarks', 'heading')) +check('t_delStandard', lambda: S.delete('Standard')) +check('t_delInUse', lambda: S.delete('Heading')) +check('t_current_missing', lambda: S.set_current('Nope')) +check('d_dup', lambda: D.create('metric')) +check('d_unknown', lambda: D.create('D1', dimbogus=1)) +check('d_handle', lambda: D.create('D2', dimtxsty_handle=5)) +check('d_name', lambda: D.modify('Metric', name='X')) +check('d_type', lambda: D.create('D3', dimscale='big')) +check('d_scale0', lambda: D.create('D4', dimscale=0)) +check('d_txt', lambda: D.create('D5', dimtxt=-1)) +check('d_txsty', lambda: D.create('D6', dimtxsty='Nope')) +check('d_copy_missing', lambda: D.create('D7', copy_from='Nope')) +check('d_delStandard', lambda: D.delete('Standard')) +check('d_modmissing', lambda: D.modify('Nope', dimscale=2)) +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "accepted invalid requests: {accepted:?}"); + assert_eq!(refused, 26); + + let check_state = |label: &str, document: &CadDocument| { + assert!(document.text_styles.get("Title").is_none() && document.text_styles.get("Notes").is_none(), "{label}: old names gone"); + let title = document.text_styles.get("Heading").unwrap_or_else(|| panic!("{label}: Heading missing")); + assert_eq!(title.height, 6.0, "{label}"); + assert!((title.width_factor - 0.8).abs() < 1e-9, "{label}"); + assert!((title.oblique_angle - 15f64.to_radians()).abs() < 1e-6, "{label}: oblique {}", title.oblique_angle); + assert_eq!(title.font_file.to_lowercase(), "romans", "{label}"); + assert_eq!(title.big_font_file.to_lowercase(), "bigfont", "{label}"); + if label == "DXF" { + // BLOCKER (cadcodec): the DXF STYLE writer hard-codes group 71 to 0, so the + // backward and upside-down generation flags are lost on a DXF save. + assert!(!title.flags.backward && !title.flags.upside_down, "{label}: cadcodec now keeps the flags; flip this canary"); + } else { + assert!(title.flags.backward && title.flags.upside_down, "{label}"); + } + let remarks = document.text_styles.get("Remarks").unwrap_or_else(|| panic!("{label}: Remarks missing")); + assert_eq!(remarks.font_file.to_lowercase(), "arial.ttf", "{label}: {remarks:?}"); + let metric = document.dim_styles.get("Metric").unwrap_or_else(|| panic!("{label}: Metric missing")); + assert_eq!((metric.dimscale, metric.dimtxt, metric.dimasz), (3.0, 3.5, 2.5), "{label}"); + if label == "DXF" { + // BLOCKER (cadcodec): the DXF DIMSTYLE reader keeps only the text-style handle + // (group 340) and never resolves `dimtxsty` from it, so the name reopens as + // "Standard". The handle survives and identifies the right style. + assert_eq!(metric.dimtxsty, "Standard", "{label}: cadcodec now resolves the name; flip this canary"); + assert_eq!(metric.dimtxsty_handle, title.handle, "{label}: text style link by handle"); + } else { + assert_eq!(metric.dimtxsty, "Heading", "{label}: dimtxsty followed the rename"); + } + let copy = document.dim_styles.get("Metric2").unwrap_or_else(|| panic!("{label}: Metric2 missing")); + assert_eq!((copy.dimscale, copy.dimtxt), (2.0, 4.0), "{label}: copied before Metric changed"); + }; + check_state("live", host.document()); + let live_title = host.document().text_styles.get("Heading").unwrap().clone(); + assert!(live_title.annotative, "annotative is kept in the live style"); + assert_eq!(host.document().dim_styles.get("Metric").unwrap().dimtxsty_handle, live_title.handle, "dim style links the text style handle"); + let dwg = crate::io::load_bytes("styles.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("styles.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + run(&mut host, "destroy", r#" +S.set_current('Remarks') +D.set_current('Metric2') +check('t_delcurrent', lambda: S.delete('Remarks')) +check('d_delcurrent', lambda: D.delete('Metric2')) +check('d_delInUseByCurrentOnly', lambda: D.delete('Metric2')) +S.set_current('Standard') +D.set_current('Standard') +S.delete('Remarks') +D.delete('Metric2') +D.delete('Metric') +S.delete('Heading') +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "{accepted:?}"); + assert_eq!(refused, 3); + let document = host.document(); + for name in ["Heading", "Remarks"] { + assert!(document.text_styles.get(name).is_none(), "{name} deleted"); + } + for name in ["Metric", "Metric2"] { + assert!(document.dim_styles.get(name).is_none(), "{name} deleted"); + } + assert_eq!(document.header.current_text_style_name, "Standard"); + assert_eq!(document.header.current_dimstyle_name, "Standard"); + + // U: the final report layer and the four deletes are five steps; undoing + // them all brings the styles back. + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(5); + let document = &app.tabs[0].scene.document; + assert!(document.text_styles.get("Heading").is_some() && document.text_styles.get("Remarks").is_some(), "undo restored the text styles"); + assert!(document.dim_styles.get("Metric").is_some() && document.dim_styles.get("Metric2").is_some(), "undo restored the dimension styles"); + } + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType; diff --git a/src/app/style_ops.rs b/src/app/style_ops.rs index e2ec1a2f2..dc645bef1 100644 --- a/src/app/style_ops.rs +++ b/src/app/style_ops.rs @@ -271,7 +271,7 @@ impl OpenCADStudio { } } - fn style_exists(&self, kind: StyleKind, name: &str) -> bool { + pub(super) fn style_exists(&self, kind: StyleKind, name: &str) -> bool { self.style_names(kind) .iter() .any(|n| n.eq_ignore_ascii_case(name)) @@ -308,7 +308,7 @@ impl OpenCADStudio { /// Refresh anything that mirrors the style list / current style after a /// mutation (ribbon dropdowns, geometry that depends on the style). - fn after_style_change(&mut self, kind: StyleKind) { + pub(super) fn after_style_change(&mut self, kind: StyleKind) { if kind.in_ribbon() { self.sync_ribbon_styles(); } @@ -405,7 +405,7 @@ impl OpenCADStudio { false } - fn remove_style_storage(&mut self, kind: StyleKind, name: &str) -> bool { + pub(super) fn remove_style_storage(&mut self, kind: StyleKind, name: &str) -> bool { let doc = &mut self.tabs[self.active_tab].scene.document; match kind { StyleKind::Text => doc.text_styles.remove(name).is_some(), @@ -541,7 +541,7 @@ impl OpenCADStudio { /// Rename `old`→`new` in the backing store, re-keying table entries and /// rewriting name-based references + current-style pointers. - fn rename_style_storage(&mut self, kind: StyleKind, old: &str, new: &str) { + pub(super) fn rename_style_storage(&mut self, kind: StyleKind, old: &str, new: &str) { let i = self.active_tab; match kind { StyleKind::Text => { From 8d20d280fe0e1bf8c8646f129700807cd3b269b1 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 16:28:03 +0200 Subject: [PATCH 52/76] feat(python): block definitions (doc.blocks) TableOperation gains BlockCreate (copy entities into a definition shifted by the base point, optionally erasing originals), BlockModify, BlockRename (inserts follow) and BlockDelete (refused while referenced; removes members, markers and orphaned draw-order tables). Real-IPC audit with DWG/DXF persistence and undo, IPC and Python unit tests, docs; the DXF loss of the block description is pinned by a canary. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 22 ++ crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 10 + docs/cadcodec-reader-gaps.md | 2 + docs/plugin-host-model-completion-plan.md | 14 + plugins/opencad-python/PLUGIN.md | 28 ++ plugins/opencad-python/src/document_model.py | 65 ++++ plugins/opencad-python/src/ocs_module.rs | 128 +++++++ .../tests/test_document_model.py | 18 + src/app/plugin_host.rs | 325 ++++++++++++++++++ 9 files changed, 612 insertions(+) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 3dc1ebf6d..cda9ac2a1 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -120,6 +120,28 @@ pub enum TableOperation { 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, + base_point: [f64; 3], + erase_originals: bool, + description: Option, + }, + /// Change a block's description, explodable or uniform-scale settings. + BlockModify { + name: String, + description: Option, + explodable: Option, + scale_uniformly: Option, + }, + /// Rename a block; every insert of it follows. + BlockRename { from: String, to: String }, + /// Delete an unreferenced block definition and its contents. + BlockDelete { name: String }, } /// The style tables `TableOperation` can rename, delete or make current. diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 1960aa9e0..b8454893d 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -131,6 +131,16 @@ mod tests { 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() }, ] { let bytes = bincode::serialize(&PluginRequest::TableOperation { operation: operation.clone() }).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), diff --git a/docs/cadcodec-reader-gaps.md b/docs/cadcodec-reader-gaps.md index 47e35e62d..a23110ff2 100644 --- a/docs/cadcodec-reader-gaps.md +++ b/docs/cadcodec-reader-gaps.md @@ -208,4 +208,6 @@ Found by `audit_python_text_and_dim_styles_over_real_ipc`; each is pinned by a c - **`DIMSTYLE` text style name:** the DXF reader keeps only the text-style handle (group 340) and never resolves `dimtxsty` from it, so the name reopens as `Standard` (the handle is right; DWG resolves both). - **`TextStyle::true_type_font`** is never written or read by either codec, so it lives only in memory. +- **`BLOCK_RECORD` description:** the DXF codec does not carry `BlockRecord::description` + (found by `audit_python_blocks_over_real_ipc`, pinned by a canary); DWG keeps it. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 1db480afc..f6e1f1d67 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -213,3 +213,17 @@ semantics, rename following references, current/delete guards, five-step undo), tests. Four cadcodec findings (STYLE flags and oblique units, DIMSTYLE name, `true_type_font`) are pinned by canaries and listed in `cadcodec-reader-gaps.md`. Next: linetypes, blocks, layouts; then headless commands. +### 2026-09-21: blocks (`doc.blocks`) + +`TableOperation` gained `BlockCreate` (copy existing model/paper entities into a definition shifted by +`-base_point`, optionally erasing the originals), `BlockModify` (description, explodable, uniform scale), +`BlockRename` (inserts follow, via `Scene::rename_block`) and `BlockDelete` (refused while an insert, a +dimension-style arrow or a multileader uses it; removes the members, markers, record and orphaned draw-order +tables). Creation reuses `Scene::define_block_from_owned_entities`. Python: `blocks.create/modify/rename/delete`, +lookup and iteration, records with member handles and kinds. Evidence: `audit_python_blocks_over_real_ipc` +(18 refusals; shifted geometry and base point; inserts following a rename; original kept vs erased; +live/DWG/DXF persistence; guarded delete; three-step undo restoring the definitions), IPC round-trip and Python +unit tests. One more cadcodec finding (DXF drops the block description) is pinned by a canary. Not covered: +editing a definition's contents in place, and attribute definitions inside a scripted block. Next: linetypes and +layouts, then headless commands. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 49a2f4b75..63190db8a 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -185,6 +185,34 @@ Known persistence limits (cadcodec, see `docs/cadcodec-reader-gaps.md`): a DXF save drops the backward/upside-down flags of a text style and does not restore a dimension style's `dimtxsty` name (the handle link survives); DWG keeps both. +### Blocks: `ocs.active_document.blocks` + +```python +doc = ocs.active_document +line = doc.create_entity("Line", start=..., end=...) +circle = doc.create_entity("Circle", center=..., radius=1) +B = doc.blocks +B.create("Widget", [line, circle], base_point=(1, 1, 0), description="a widget") +doc.create_entity("Insert", block_name="Widget", insert_point={"x": 10, "y": 0, "z": 0}) +B.modify("Widget", explodable=False) +B.rename("Widget", "Gadget") # every insert follows +B["Gadget"]["entities"], B["Gadget"]["insert_count"], B.names() +B.delete("Gadget") # refused while an insert, style or leader uses it +``` + +`create` copies model or paper space entities (descriptors or handles) into a new +definition shifted by `-base_point`, so an insert at `p` puts the base point at +`p`. `erase_originals=True` removes the sources, as the BLOCK command does; the +default keeps them. No insert is placed. Refused: an empty, duplicate, invalid or +`*`-prefixed name, no entities, a missing or repeated entity, an entity inside a +block, a viewport, a non-finite base point, renaming or deleting layout, +anonymous or externally referenced blocks, and a case-only rename. Deleting also +removes the definition's contents. Records list user blocks only (`handle`, +`name`, `description`, `explodable`, `scale_uniformly`, `base_point`, +`insert_count`, `entities` as `{handle, kind}`). Editing a definition's contents +in place and creating attribute definitions inside a block from a script are not +available yet. A DXF save drops the block description (cadcodec); DWG keeps it. + ### Historical command-replay experiment (not in the default build) The following records an earlier experiment. The bundled build does **not** diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 61dfa2fa9..b414bf8cf 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -396,12 +396,77 @@ class _DimStyles(_Styles): return self[name] +class _Blocks: + """`ocs.active_document.blocks`: user block definitions (layout and + anonymous blocks are not listed). Every change is validated by the host, is + one undo step, and a refusal raises `RuntimeError` without changing the + drawing. Place a block with `create_entity("Insert", block_name=...)`.""" + + def _records(self): + return ocs.block_records() + + def __iter__(self): + return iter(self._records()) + + def __len__(self): + return len(self._records()) + + def __contains__(self, name): + wanted = str(name).strip().upper() + return any(r["name"].upper() == wanted for r in self._records()) + + def __getitem__(self, name): + wanted = str(name).strip().upper() + for record in self._records(): + if record["name"].upper() == wanted: + return record + raise KeyError(name) + + def get(self, name, default=None): + try: + return self[name] + except KeyError: + return default + + def names(self): + return [r["name"] for r in self._records()] + + def create(self, name, entities, base_point=(0, 0, 0), erase_originals=False, description=None): + """Define a block from existing model/paper-space entities (descriptors + or handles). They are copied into the definition shifted by + `-base_point`; `erase_originals=True` removes them from the drawing, as + the BLOCK command does. No insert is placed.""" + handles = [e.handle if isinstance(e, _Entity) else int(e) for e in entities] + options = {"entities": handles, "base_point": [float(v) for v in base_point], + "erase_originals": bool(erase_originals)} + if description is not None: + options["description"] = str(description) + ocs.block_operation("create", str(name), options) + return self[name] + + def modify(self, name, description=None, explodable=None, scale_uniformly=None): + options = {k: v for k, v in (("description", description), ("explodable", explodable), + ("scale_uniformly", scale_uniformly)) if v is not None} + ocs.block_operation("modify", str(name), options) + return self[name] + + def rename(self, name, new_name): + """Rename a block; every insert of it follows.""" + ocs.block_operation("rename", str(name), {"to": str(new_name)}) + return self[new_name] + + def delete(self, name): + """Delete a block that no insert, style or leader still uses.""" + ocs.block_operation("delete", str(name), None) + + class _Document: def __init__(self): self._pending = None self.layers = _Layers() self.text_styles = _TextStyles() self.dim_styles = _DimStyles() + self.blocks = _Blocks() self.entities = _Entities(self) self.solids = _Solids(self) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 42c5804d3..711456616 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -1270,6 +1270,134 @@ mod ocs { Ok(vm.ctx.new_list(output).into()) } + /// Block-definition change through the host. `op` is `create` + /// (`entities`, `base_point`, `erase_originals`, `description`), `modify` + /// (`description`, `explodable`, `scale_uniformly`), `rename` (`to`) or + /// `delete`. Returns the block record's handle. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn block_operation(op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::TableOperation; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let operation = match op.as_str() { + "create" => { + ensure_known_entity_keys(&options, "block", &["entities", "base_point", "erase_originals", "description"], vm)?; + let handles = present("entities")? + .ok_or_else(|| vm.new_value_error("ocs: a block needs entities".to_owned()))? + .try_into_value::>(vm)?; + let base_point = match present("base_point")? { + Some(value) => py_to_f64_array::<3>(value, vm)?, + None => [0.0; 3], + }; + TableOperation::BlockCreate { + name, + entities: handles.into_iter().map(Handle::new).collect(), + base_point, + erase_originals: get_opt_bool(&options, "erase_originals", vm)?, + description: present("description")?.map(|v| v.try_into_value::(vm)).transpose()?, + } + } + "modify" => { + ensure_known_entity_keys(&options, "block", &["description", "explodable", "scale_uniformly"], vm)?; + TableOperation::BlockModify { + name, + description: present("description")?.map(|v| v.try_into_value::(vm)).transpose()?, + explodable: present("explodable")?.map(|v| v.try_into_value::(vm)).transpose()?, + scale_uniformly: present("scale_uniformly")?.map(|v| v.try_into_value::(vm)).transpose()?, + } + } + "rename" => { + ensure_known_entity_keys(&options, "block rename", &["to"], vm)?; + let to = options + .get_item_opt("to", vm)? + .ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))? + .try_into_value::(vm)?; + TableOperation::BlockRename { from: name, to } + } + "delete" => { + ensure_known_entity_keys(&options, "block delete", &[], vm)?; + TableOperation::BlockDelete { name } + } + other => return Err(vm.new_value_error(format!("ocs.block_operation: unknown operation {other:?}"))), + }; + let result = host_ctx::with_host(|host| host.table_operation(operation)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.block_operation: {error}"))) + } + + /// Every user block definition (layouts and anonymous blocks are omitted). + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn block_records(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let mut inserts = std::collections::HashMap::::new(); + for entity in document.entities() { + if let acadrust::entities::EntityType::Insert(insert) = entity { + *inserts.entry(insert.block_name.to_uppercase()).or_default() += 1; + } + } + document + .block_records + .iter() + .filter(|record| !record.is_layout() && !record.is_anonymous() && !record.flags.is_xref) + .map(|record| { + let members: Vec<(u64, String)> = record + .entity_handles + .iter() + .filter_map(|handle| document.get_entity(*handle)) + .map(|entity| { + let kind = ocs_plugin_api::entity_coverage::entity_snapshot(entity) + .ok() + .and_then(|value| value.as_object().and_then(|o| o.keys().next().cloned())) + .unwrap_or_else(|| "Unknown".to_owned()); + (entity.common().handle.value(), kind) + }) + .collect(); + ( + record.clone(), + members, + inserts.get(&record.name.to_uppercase()).copied().unwrap_or(0), + ) + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (record, members, insert_count) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(record.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(record.name.clone()), vm)?; + dict.set_item("description", vm.new_pyobj(record.description.clone()), vm)?; + dict.set_item("explodable", vm.new_pyobj(record.explodable), vm)?; + dict.set_item("scale_uniformly", vm.new_pyobj(record.scale_uniformly), vm)?; + let base = [record.base_point.x, record.base_point.y, record.base_point.z]; + dict.set_item("base_point", PyObjectRef::from(vm.ctx.new_list(base.iter().map(|v| vm.new_pyobj(*v)).collect())), vm)?; + dict.set_item("insert_count", vm.new_pyobj(insert_count), vm)?; + let entities = members + .into_iter() + .map(|(handle, kind)| { + let member = vm.ctx.new_dict(); + member.set_item("handle", vm.new_pyobj(handle), vm)?; + member.set_item("kind", vm.new_pyobj(kind), vm)?; + Ok(PyObjectRef::from(member)) + }) + .collect::>>()?; + dict.set_item("entities", PyObjectRef::from(vm.ctx.new_list(entities)), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + /// Every layer with its full properties, in table order. #[cfg(feature = "experimental-host-model")] #[pyfunction] diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index cdd7dd999..8823092c4 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -39,6 +39,8 @@ class DocumentModelTests(unittest.TestCase): text_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Title", "Notes")], dim_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Metric")], style_operation=lambda kind, op, name, options: self.calls.append((kind, op, name, options)) or 9, + block_records=lambda: [{"name": n, "entities": []} for n in ("Widget", "Gadget")], + block_operation=lambda op, name, options: self.calls.append(("block", op, name, options)) or 9, ) namespace = {"ocs": self.ocs} exec(MODEL.read_text(), namespace) @@ -94,6 +96,22 @@ class DocumentModelTests(unittest.TestCase): dim.set_current("Standard") self.assertEqual(self.calls[-1], ("dim", "set_current", "Standard", None)) + def test_blocks(self): + blocks = self.doc.blocks + self.assertIn("WIDGET", blocks) + self.assertEqual(blocks.names(), ["Widget", "Gadget"]) + blocks.create("Gadget", [self.doc.entities[1], 2], base_point=(1, 2, 3), erase_originals=True) + self.assertEqual(self.calls[-1], ("block", "create", "Gadget", + {"entities": [1, 2], "base_point": [1.0, 2.0, 3.0], "erase_originals": True})) + blocks.modify("Widget", explodable=False) + self.assertEqual(self.calls[-1], ("block", "modify", "Widget", {"explodable": False})) + blocks.rename("Widget", "Gadget") + self.assertEqual(self.calls[-1], ("block", "rename", "Widget", {"to": "Gadget"})) + blocks.delete("Widget") + self.assertEqual(self.calls[-1], ("block", "delete", "Widget", None)) + with self.assertRaises(KeyError): + blocks["Nope"] + def test_create_and_delete_use_document_model(self): line = self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, end={"x": 10, "y": 0, "z": 0}) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 163d6d43d..ba60109b5 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -1245,10 +1245,197 @@ impl<'a> HostSession<'a> { self.app.set_current_layer_name(self.tab, &stored)?; Ok(handle) } + other @ (TableOperation::BlockCreate { .. } + | TableOperation::BlockModify { .. } + | TableOperation::BlockRename { .. } + | TableOperation::BlockDelete { .. }) => self.block_operation(other), other => self.style_operation(other), } } + /// Block definition operations: create from entities, modify settings, + /// rename (inserts follow) and delete an unreferenced definition. + fn block_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { + use acadrust::entities::EntityType as E; + use ocs_plugin_api::host::TableOperation; + let editable_block = |doc: &CadDocument, name: &str| -> Result<(String, Handle), String> { + let record = doc + .block_records + .get(name) + .ok_or_else(|| format!("block {name:?} does not exist"))?; + if record.is_layout() || record.is_anonymous() || record.flags.is_xref || record.flags.is_xref_overlay || record.name.contains('|') { + return Err(format!("block {:?} is a layout, anonymous or externally referenced block", record.name)); + } + Ok((record.name.clone(), record.handle)) + }; + match operation { + TableOperation::BlockCreate { name, entities, base_point, erase_originals, description } => { + let name = validated_symbol_name(&name, "a block name")?; + if name.starts_with('*') { + return Err("a block name cannot start with '*'".to_owned()); + } + if self.document().block_records.get(&name).is_some() { + return Err(format!("block {name:?} already exists")); + } + if base_point.iter().any(|v| !v.is_finite()) { + return Err("the block base point must be finite".to_owned()); + } + if entities.is_empty() { + return Err("a block needs at least one entity".to_owned()); + } + let mut seen = std::collections::HashSet::new(); + let mut sources = Vec::with_capacity(entities.len()); + for handle in &entities { + if !seen.insert(handle.value()) { + return Err(format!("entity {} is listed twice", handle.value())); + } + let entity = self + .document() + .get_entity(*handle) + .ok_or_else(|| format!("entity {} does not exist", handle.value()))?; + let owner = entity.common().owner_handle; + let in_canvas = self + .document() + .block_records + .iter() + .any(|record| record.handle == owner && record.is_layout()); + if !in_canvas { + return Err(format!("entity {} is not in model or paper space", handle.value())); + } + if matches!(entity, E::Viewport(_) | E::Block(_) | E::BlockEnd(_) | E::AttributeEntity(_) | E::Unknown(_)) { + return Err(format!("entity {} of this kind cannot be placed in a block", handle.value())); + } + sources.push(entity.clone()); + } + if let Some(text) = &description { + if text.chars().any(|c| c.is_control() && c != '\n') { + return Err("a block description cannot contain control characters".to_owned()); + } + } + self.push_undo("Create block"); + let base = glam::DVec3::new(base_point[0], base_point[1], base_point[2]); + self.app.tabs[self.tab] + .scene + .define_block_from_owned_entities(sources, &name, base)?; + let (stored, handle) = editable_block(self.document(), &name)?; + if let Some(record) = self.document_mut().block_records.get_mut(&stored) { + record.base_point = acadrust::types::Vector3::new(base_point[0], base_point[1], base_point[2]); + if let Some(text) = description { + record.description = text; + } + } + let marker = self.document().block_records.get(&stored).map(|r| r.block_entity_handle); + if let Some(marker) = marker { + if let Some(E::Block(block)) = self.document_mut().get_entity_mut(marker) { + block.base_point = acadrust::types::Vector3::new(base_point[0], base_point[1], base_point[2]); + } + } + if erase_originals { + self.app.tabs[self.tab].scene.erase_entities(&entities); + } + self.finish_block_change(); + Ok(handle) + } + TableOperation::BlockModify { name, description, explodable, scale_uniformly } => { + let (name, handle) = editable_block(self.document(), name.trim())?; + if description.is_none() && explodable.is_none() && scale_uniformly.is_none() { + return Err("no block properties to change".to_owned()); + } + if let Some(text) = &description { + if text.chars().any(|c| c.is_control() && c != '\n') { + return Err("a block description cannot contain control characters".to_owned()); + } + } + self.push_undo("Modify block"); + if let Some(record) = self.document_mut().block_records.get_mut(&name) { + if let Some(text) = description { + record.description = text; + } + if let Some(value) = explodable { + record.explodable = value; + } + if let Some(value) = scale_uniformly { + record.scale_uniformly = value; + } + } + self.finish_block_change(); + Ok(handle) + } + TableOperation::BlockRename { from, to } => { + let (from, handle) = editable_block(self.document(), from.trim())?; + let to = validated_symbol_name(&to, "a block name")?; + if to.starts_with('*') { + return Err("a block name cannot start with '*'".to_owned()); + } + if from.eq_ignore_ascii_case(&to) { + return Err("the new block name matches the current one (a case-only change is not supported)".to_owned()); + } + if self.document().block_records.get(&to).is_some() { + return Err(format!("block {to:?} already exists")); + } + self.push_undo("Rename block"); + if !self.app.tabs[self.tab].scene.rename_block(&from, &to) { + return Err("the block could not be renamed".to_owned()); + } + self.finish_block_change(); + Ok(handle) + } + TableOperation::BlockDelete { name } => { + let (name, handle) = editable_block(self.document(), name.trim())?; + let doc = self.document(); + let referenced = doc.entities().any(|entity| match entity { + E::Insert(insert) => insert.block_name.eq_ignore_ascii_case(&name), + E::MultiLeader(leader) => { + leader.block_content_handle == Some(handle) + || leader.context.block_content_handle == Some(handle) + } + _ => false, + }) || doc.dim_styles.iter().any(|style| { + [style.dimblk, style.dimblk1, style.dimblk2, style.dimldrblk].contains(&handle) + }); + if referenced { + return Err(format!("block {name:?} is still inserted or used by a style or leader")); + } + let members: Vec = { + let record = doc.block_records.get(&name).expect("checked above"); + let mut members = record.entity_handles.clone(); + members.push(record.block_entity_handle); + members.push(record.block_end_handle); + members + }; + self.push_undo("Delete block"); + let doc = self.document_mut(); + for member in members { + doc.remove_entity(member); + } + doc.block_records.remove(&name); + let orphans: Vec = { + let live: std::collections::HashSet = doc.block_records.iter().map(|r| r.handle).collect(); + doc.objects + .iter() + .filter_map(|(h, object)| match object { + acadrust::objects::ObjectType::SortEntitiesTable(table) if !live.contains(&table.block_owner_handle) => Some(*h), + _ => None, + }) + .collect() + }; + for orphan in orphans { + doc.objects.remove(&orphan); + } + self.finish_block_change(); + Ok(handle) + } + _ => Err("unsupported table operation".to_owned()), + } + } + + fn finish_block_change(&mut self) { + self.app.tabs[self.tab].dirty = true; + self.app.tabs[self.tab].scene.invalidate_dependency_index(); + self.app.tabs[self.tab].scene.bump_geometry(); + self.publish_document_view(); + } + /// Text and dimension style operations. They act on the active document /// because the shared style machinery (in-use scan, rename, ribbon sync) is /// keyed by the active tab. @@ -6814,6 +7001,144 @@ S.delete('Heading') assert!(document.dim_styles.get("Metric").is_some() && document.dim_styles.get("Metric2").is_some(), "undo restored the dimension styles"); } + /// Block definitions through Python over the real runner: create from + /// entities (with and without erasing them), modify, rename with inserts + /// following, guarded delete, DWG/DXF persistence and undo. + #[test] + fn audit_python_blocks_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_blocks_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\n", + "doc = ocs.active_document\nB = doc.blocks\nL = doc.layers\n", + "refused, accepted = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n accepted.append(tag)\n", + "try:\n{body}\n", + "except Exception as error:\n", + " accepted.append('SCRIPTERROR_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:120])\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(accepted))\n", + ), body = body.lines().map(|line| format!(" {line}")).collect::>().join("\n"))).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, accepted) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), accepted.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +line = doc.create_entity('Line', start=P(0, 0, 0), end=P(4, 0, 0)) +circle = doc.create_entity('Circle', center=P(2, 2, 0), radius=1) +B.create('Widget', [line, circle], base_point=(1, 1, 0), description='a widget') +B.create('Widget2', [line.handle], erase_originals=True) +doc.create_entity('Insert', block_name='Widget', insert_point=P(10, 0, 0)) +doc.create_entity('Insert', block_name='widget', insert_point=P(20, 0, 0)) +B.modify('Widget', explodable=False, description='a gadget') +B.rename('Widget', 'Gadget') +member = B['Gadget']['entities'][0]['handle'] +check('dup', lambda: B.create('gadget', [circle])) +check('empty', lambda: B.create('E1', [])) +check('badname', lambda: B.create('a Vec { + let record = document.block_records.get(name).unwrap_or_else(|| panic!("{label}: block {name} missing")); + document.entities().filter(|e| e.common().owner_handle == record.handle + && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))).cloned().collect() + }; + let gadget = members("Gadget"); + assert_eq!(gadget.len(), 2, "{label}: Gadget members"); + let line = gadget.iter().find_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: line")); + assert_eq!((line.start.x, line.start.y, line.end.x, line.end.y), (-1.0, -1.0, 3.0, -1.0), "{label}: shifted by -base"); + let circle = gadget.iter().find_map(|e| match e { EntityType::Circle(c) => Some(c.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: circle")); + assert_eq!((circle.center.x, circle.center.y, circle.radius), (1.0, 1.0, 1.0), "{label}"); + let record = document.block_records.get("Gadget").unwrap(); + assert_eq!((record.base_point.x, record.base_point.y), (1.0, 1.0), "{label}: base point"); + assert!(!record.explodable, "{label}: explodable"); + if label == "DXF" { + // BLOCKER (cadcodec): the DXF BLOCK_RECORD reader/writer does not carry the + // description, so it is lost on a DXF save (DWG keeps it). + assert_eq!(record.description, "", "{label}: cadcodec now keeps the description; flip this canary"); + } else { + assert_eq!(record.description, "a gadget", "{label}: description"); + } + assert_eq!(members("Widget2").len(), 1, "{label}: Widget2 members"); + let inserts: Vec<_> = document.entities().filter_map(|e| match e { EntityType::Insert(i) => Some(i.block_name.clone()), _ => None }).collect(); + assert_eq!(inserts.iter().filter(|n| n.as_str() == "Gadget").count(), 2, "{label}: inserts follow the rename: {inserts:?}"); + // originals: Widget kept the circle and line in model space, Widget2 erased its line copy's source + let canvas_kinds: Vec<_> = document.entities().filter(|e| { + document.block_records.iter().any(|r| r.handle == e.common().owner_handle && r.is_layout()) + }).map(|e| format!("{:?}", e).chars().take(6).collect::()).collect(); + assert_eq!(canvas_kinds.iter().filter(|k| k.starts_with("Circle")).count(), 1, "{label}: circle original kept: {canvas_kinds:?}"); + assert_eq!(canvas_kinds.iter().filter(|k| k.starts_with("Line(")).count(), 0, "{label}: line original erased: {canvas_kinds:?}"); + }; + check_state("live", host.document()); + assert_eq!(host.document().block_records.get("Gadget").unwrap().description, "a gadget"); + let dwg = crate::io::load_bytes("blocks.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("blocks.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + run(&mut host, "destroy", r#" +for entity in list(doc.entities): + if entity.kind == 'Insert': + doc.delete_entity(entity) +B.delete('Gadget') +B.delete('Widget2') +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "{accepted:?}"); + assert_eq!(refused, 0); + let document = host.document(); + assert!(document.block_records.get("Gadget").is_none() && document.block_records.get("Widget2").is_none()); + assert!(!document.entities().any(|e| matches!(e, EntityType::Line(_))), "block members were removed with their definitions"); + assert_eq!(document.entities().filter(|e| matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))).count(), 0); + + // U: the report layer and both block deletes; undoing them restores the definitions. + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(3); + let document = &app.tabs[0].scene.document; + let members = |name: &str| document.block_records.get(name).map(|r| r.entity_handles.len()); + assert_eq!(members("Gadget"), Some(2), "undo restored Gadget with its members"); + assert_eq!(members("Widget2"), Some(1), "undo restored Widget2"); + } + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType; From b1edc9f5787fe8acdd0d204645d6b6c5d0e69b3c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 16:54:30 +0200 Subject: [PATCH 53/76] feat(python): block contents (create_entity(..., block=)) BlockEntityAdd adds a validated entity to a block definition (owner set by the host, same validation as a model-space entity, self-nesting inserts refused), reusing the scene's block-editor routing. Deleting a block member now also drops its handle from the block record. Real-IPC audit with an attribute definition inside a block, DWG/DXF persistence and undo; IPC and Python unit tests; docs. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 5 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 4 + docs/plugin-host-model-completion-plan.md | 13 ++ plugins/opencad-python/PLUGIN.md | 17 +- plugins/opencad-python/src/document_model.py | 12 +- plugins/opencad-python/src/ocs_module.rs | 25 +++ .../tests/test_document_model.py | 9 + src/app/plugin_host.rs | 173 +++++++++++++++++- 8 files changed, 251 insertions(+), 7 deletions(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index cda9ac2a1..1f8903987 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -142,6 +142,11 @@ pub enum TableOperation { 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 }, } /// The style tables `TableOperation` can rename, delete or make current. diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index b8454893d..aee1a13f2 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -141,6 +141,10 @@ mod tests { 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()), + }, ] { let bytes = bincode::serialize(&PluginRequest::TableOperation { operation: operation.clone() }).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index f6e1f1d67..0ca0d3740 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -227,3 +227,16 @@ unit tests. One more cadcodec finding (DXF drops the block description) is pinne editing a definition's contents in place, and attribute definitions inside a scripted block. Next: linetypes and layouts, then headless commands. +### 2026-09-21: block contents (`create_entity(..., block=)`) + +`TableOperation::BlockEntityAdd` adds a validated entity to a definition: the host sets the owner, runs the same +validation and reference binding as a new model-space entity, refuses viewports, raster images, block markers, +nested attributes and self-nesting inserts (direct or transitive), then reuses the scene's block-editor routing so +the entity gets normal preparation. Editing and deleting members already worked through the entity API; deleting a +member now also drops its handle from the block record, because the core document leaves a removed entity +listed (`entity_handles`). An entity-delta undo of an add still leaves the handle listed (documented scene behaviour), +so consumers must treat `entity_handles` as candidates and confirm with `get_entity`; the Python block records +already do. Evidence: `audit_python_block_contents_over_real_ipc` (9 refusals; create, edit, delete in a block; +attribute definition inside a block; nested insert; live/DWG/DXF persistence; undo), IPC and Python unit tests. +Next: linetypes and layouts, then headless commands. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 63190db8a..4533d285e 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -209,9 +209,20 @@ block, a viewport, a non-finite base point, renaming or deleting layout, anonymous or externally referenced blocks, and a case-only rename. Deleting also removes the definition's contents. Records list user blocks only (`handle`, `name`, `description`, `explodable`, `scale_uniformly`, `base_point`, -`insert_count`, `entities` as `{handle, kind}`). Editing a definition's contents -in place and creating attribute definitions inside a block from a script are not -available yet. A DXF save drops the block description (cadcodec); DWG keeps it. +`insert_count`, `entities` as `{handle, kind}`). A DXF save drops the block +description (cadcodec); DWG keeps it. + +**Block contents.** Add to a definition with `create_entity(kind, block="Part", +...)`. It is validated exactly like a model-space entity and the host sets the +owner (so attribute definitions work: `create_entity("AttributeDefinition", +block="Part", tag=..., prompt=..., default_value=..., insertion_point=..., +height=...)`). Members are read, edited (`entity.end = (8, 0, 0)` inside +`doc.transaction(...)`) and deleted (`doc.delete_entity`) with the ordinary entity +API, using the handles in `blocks["Part"]["entities"]`. Refused: an unknown, +layout, anonymous or external block, an invalid entity, a viewport, raster image, +block marker or nested attribute, an entity on a locked layer, and an insert that +would nest a block inside itself directly or through other blocks. Adding while the +block editor is open is refused. Each add or delete is one undo step. ### Historical command-replay experiment (not in the default build) diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index b414bf8cf..8c1894b88 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -473,11 +473,17 @@ class _Document: def transaction(self, label): return _Transaction(self, label) - def create_entity(self, kind, **properties): - """Create one mapped entity and return its live document descriptor.""" + def create_entity(self, kind, block=None, **properties): + """Create one mapped entity and return its live document descriptor. + With `block="Name"` the entity is added to that block definition + instead of the drawing (its owner is set by the host).""" if "kind" in properties or "handle" in properties: raise ValueError("kind and handle are managed by create_entity") - handle = ocs.add(dict(kind=kind, **_coerce_points(kind, properties))) + entity = dict(kind=kind, **_coerce_points(kind, properties)) + if block is not None: + handle = ocs.add_to_block(str(block), entity) + else: + handle = ocs.add(entity) return self.entities[handle] def delete_entity(self, entity): diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 711456616..4e1667330 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4263,6 +4263,31 @@ mod ocs { .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? } + /// Add a new entity, built from `entity` like `add`, to the block definition + /// `block`. Returns the new entity's handle. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn add_to_block(block: String, entity: PyObjectRef, vm: &VirtualMachine) -> PyResult { + let dict = entity.try_into_value::(vm)?; + let mut built = dict_to_entity(&dict, vm)?; + let result = host_ctx::with_host(|host| -> PyResult> { + let owner = host + .document() + .block_records + .get(block.trim()) + .map(|record| record.handle) + .ok_or_else(|| vm.new_runtime_error(format!("ocs.add_to_block: block {block:?} does not exist")))?; + built.common_mut().owner_handle = owner; + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&built) + .map_err(|error| vm.new_value_error(error))?; + Ok(host.table_operation(ocs_plugin_api::host::TableOperation::BlockEntityAdd { block, entity: built })) + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))??; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.add_to_block: {error}"))) + } + /// Update `handle` by merging `entity`'s keys onto its *current* value — /// a key `entity` doesn't mention is left exactly as it was, not reset to /// a type default (Felix's Phase 1 review: reconstructing from a partial diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index 8823092c4..8e9f5ac3d 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -41,6 +41,7 @@ class DocumentModelTests(unittest.TestCase): style_operation=lambda kind, op, name, options: self.calls.append((kind, op, name, options)) or 9, block_records=lambda: [{"name": n, "entities": []} for n in ("Widget", "Gadget")], block_operation=lambda op, name, options: self.calls.append(("block", op, name, options)) or 9, + add_to_block=lambda block, entity: self.calls.append(("add_to_block", block, entity)) or 1, ) namespace = {"ocs": self.ocs} exec(MODEL.read_text(), namespace) @@ -96,6 +97,14 @@ class DocumentModelTests(unittest.TestCase): dim.set_current("Standard") self.assertEqual(self.calls[-1], ("dim", "set_current", "Standard", None)) + def test_create_entity_in_block(self): + self.doc.create_entity("Line", block="Widget", start={"x": 0, "y": 0, "z": 0}, end={"x": 1, "y": 0, "z": 0}) + self.assertEqual(self.calls[-1][:2], ("add_to_block", "Widget")) + self.assertEqual(self.calls[-1][2]["kind"], "Line") + self.assertNotIn("block", self.calls[-1][2]) + self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, end={"x": 1, "y": 0, "z": 0}) + self.assertEqual(self.calls[-1][0], "add") + def test_blocks(self): blocks = self.doc.blocks self.assertIn("WIDGET", blocks) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index ba60109b5..2ae2d1172 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -503,9 +503,18 @@ impl<'a> HostSession<'a> { } return removed; } + let owner = self.document().get_entity(handle).map(|entity| entity.common().owner_handle); self.app.tabs[self.tab].scene.erase_entities(&[handle]); let removed = self.document().get_entity(handle).is_none(); if removed { + // The document keeps a block record's member list separately from the + // entity store, and removing an entity leaves its handle listed; drop + // it so a definition never lists a member that no longer exists. + if let Some(owner) = owner { + if let Some(record) = self.document_mut().block_records.iter_mut().find(|record| record.handle == owner) { + record.entity_handles.retain(|member| *member != handle); + } + } self.publish_document_view(); } removed @@ -1248,7 +1257,8 @@ impl<'a> HostSession<'a> { other @ (TableOperation::BlockCreate { .. } | TableOperation::BlockModify { .. } | TableOperation::BlockRename { .. } - | TableOperation::BlockDelete { .. }) => self.block_operation(other), + | TableOperation::BlockDelete { .. } + | TableOperation::BlockEntityAdd { .. }) => self.block_operation(other), other => self.style_operation(other), } } @@ -1336,6 +1346,42 @@ impl<'a> HostSession<'a> { self.finish_block_change(); Ok(handle) } + TableOperation::BlockEntityAdd { block, mut entity } => { + let (name, record_handle) = editable_block(self.document(), block.trim())?; + if matches!( + entity, + E::Viewport(_) | E::Block(_) | E::BlockEnd(_) | E::AttributeEntity(_) | E::Unknown(_) | E::RasterImage(_) + ) { + return Err("an entity of this kind cannot be added to a block from a script".to_owned()); + } + if self.app.tabs[self.tab].scene.block_edit_block.is_some() { + return Err("finish the open block editor before adding entities to a block".to_owned()); + } + if let E::Insert(insert) = &entity { + if insert.block_name.eq_ignore_ascii_case(&name) || block_nests(self.document(), &insert.block_name, &name, 0) { + return Err(format!("inserting {:?} into {name:?} would nest a block inside itself", insert.block_name)); + } + } + entity.common_mut().owner_handle = record_handle; + ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&entity)?; + ocs_plugin_api::entity_coverage::bind_canvas_entity_references(self.document(), &mut entity)?; + if self.document().layers.get(&entity.common().layer).is_some_and(|layer| layer.is_locked()) { + return Err(format!("layer {:?} is locked", entity.common().layer)); + } + self.push_undo("Add to block"); + entity.common_mut().handle = Handle::NULL; + entity.common_mut().owner_handle = record_handle; + // The scene routes an add into the block being edited; reuse that + // path so the entity gets the same preparation as any new entity. + self.app.tabs[self.tab].scene.block_edit_block = Some(record_handle); + let handle = self.add_entity(entity); + self.app.tabs[self.tab].scene.block_edit_block = None; + if handle.is_null() { + return Err(format!("the entity could not be added to block {name:?}")); + } + self.finish_block_change(); + Ok(handle) + } TableOperation::BlockModify { name, description, explodable, scale_uniformly } => { let (name, handle) = editable_block(self.document(), name.trim())?; if description.is_none() && explodable.is_none() && scale_uniformly.is_none() { @@ -1666,6 +1712,22 @@ impl<'a> HostSession<'a> { } } +/// True when block `outer` (transitively) contains an insert of `target`. +fn block_nests(doc: &CadDocument, outer: &str, target: &str, depth: usize) -> bool { + if depth > 64 { + return true; + } + let Some(record) = doc.block_records.get(outer) else { + return false; + }; + record.entity_handles.iter().filter_map(|h| doc.get_entity(*h)).any(|entity| match entity { + EntityType::Insert(insert) => { + insert.block_name.eq_ignore_ascii_case(target) || block_nests(doc, &insert.block_name, target, depth + 1) + } + _ => false, + }) +} + /// A layer name AutoCAD would accept: 1-255 characters, none of `<>/\":;?*|=\``. fn validated_layer_name(raw: &str) -> Result { validated_symbol_name(raw, "a layer name") @@ -7139,6 +7201,115 @@ B.delete('Widget2') assert_eq!(members("Widget2"), Some(1), "undo restored Widget2"); } + /// Block contents through Python over the real runner: add entities + /// (including an attribute definition) to a definition, edit and delete them + /// with the ordinary entity API, refuse bad requests, persist through + /// DWG/DXF and undo. + #[test] + fn audit_python_block_contents_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_bcontents_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\n", + "doc = ocs.active_document\nB = doc.blocks\nL = doc.layers\n", + "refused, accepted = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n accepted.append(tag)\n", + "try:\n{body}\n", + "except Exception as error:\n", + " accepted.append('SCRIPTERROR_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:120])\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(accepted))\n", + ), body = body.lines().map(|line| format!(" {line}")).collect::>().join("\n"))).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, accepted) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), accepted.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +seed = doc.create_entity('Circle', center=P(0, 0, 0), radius=1) +B.create('Part', [seed], erase_originals=True) +line = doc.create_entity('Line', start=P(0, 0, 0), end=P(5, 0, 0), block='Part') +arc = doc.create_entity('Arc', center=P(0, 0, 0), radius=5.0, start_angle=0.0, end_angle=1.5, block='Part') +tag = doc.create_entity('AttributeDefinition', block='Part', tag='PART_NO', prompt='Part number', default_value='PN-001', insertion_point=P(1, 2, 0), height=2.5) +with doc.transaction('Lengthen'): + line.end = (8, 0, 0) +doc.delete_entity(arc) +B.create('Outer', [doc.create_entity('Circle', center=P(0, 0, 0), radius=1)], erase_originals=True) +doc.create_entity('Insert', block_name='Part', insert_point=P(0, 0, 0), block='Outer') +check('missing_block', lambda: doc.create_entity('Line', start=P(0, 0, 0), end=P(1, 0, 0), block='Nope')) +check('layout_block', lambda: doc.create_entity('Line', start=P(0, 0, 0), end=P(1, 0, 0), block='*Model_Space')) +check('zero_circle', lambda: doc.create_entity('Circle', center=P(1, 1, 0), radius=0, block='Part')) +check('viewport', lambda: doc.create_entity('Viewport', block='Part')) +check('bad_tag', lambda: doc.create_entity('AttributeDefinition', block='Part', tag=' ', prompt='p', default_value='', insertion_point=P(0, 0, 0), height=1)) +check('bad_height', lambda: doc.create_entity('AttributeDefinition', block='Part', tag='T', prompt='p', default_value='', insertion_point=P(0, 0, 0), height=0)) +check('self_insert', lambda: doc.create_entity('Insert', block_name='Part', insert_point=P(0, 0, 0), block='Part')) +check('cycle', lambda: doc.create_entity('Insert', block_name='Outer', insert_point=P(0, 0, 0), block='Part')) +check('unknown_insert_block', lambda: doc.create_entity('Insert', block_name='Ghost', insert_point=P(0, 0, 0), block='Part')) +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "accepted invalid requests: {accepted:?}"); + assert_eq!(refused, 9); + + let check_state = |label: &str, document: &CadDocument| { + let record = document.block_records.get("Part").unwrap_or_else(|| panic!("{label}: Part missing")); + let members: Vec = document.entities() + .filter(|e| e.common().owner_handle == record.handle && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))) + .cloned().collect(); + assert_eq!(members.len(), 3, "{label}: circle, line, attribute definition (the arc was deleted): {:?}", + members.iter().map(|e| format!("{e:?}").chars().take(10).collect::()).collect::>()); + let line = members.iter().find_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: line")); + assert_eq!((line.end.x, line.end.y), (8.0, 0.0), "{label}: edited inside the block"); + assert!(members.iter().any(|e| matches!(e, EntityType::Circle(_))), "{label}: circle from the seed"); + let attdef = members.iter().find_map(|e| match e { EntityType::AttributeDefinition(a) => Some(a.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: attdef")); + assert_eq!((attdef.tag.as_str(), attdef.prompt.as_str(), attdef.default_value.as_str()), ("PART_NO", "Part number", "PN-001"), "{label}"); + assert!(!members.iter().any(|e| matches!(e, EntityType::Arc(_))), "{label}: arc deleted"); + let outer = document.block_records.get("Outer").unwrap_or_else(|| panic!("{label}: Outer missing")); + let nested = document.entities().any(|e| matches!(e, EntityType::Insert(i) if i.block_name == "Part" && i.common.owner_handle == outer.handle)); + assert!(nested, "{label}: Part is inserted inside Outer"); + }; + check_state("live", host.document()); + let dwg = crate::io::load_bytes("bc.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("bc.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + // U: one more add, then the report layer; undoing both leaves the block as before. + run(&mut host, "extra", "doc.create_entity('Circle', center=P(9, 9, 0), radius=2, block='Part')"); + assert_eq!(report(host.document()).1.len(), 0); + { + let document = host.document(); + let record = document.block_records.get("Part").unwrap(); + let live: Vec<_> = record.entity_handles.iter().map(|h| (h.value(), document.get_entity(*h).is_some())).collect(); + assert_eq!(record.entity_handles.len(), 4, "record entity_handles (handle, exists): {live:?}"); + } + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(2); + // Membership bookkeeping is intrinsic to an add and stays listed across an entity-delta + // undo (see `Scene::add_entity_internal`), so count the entities that really exist. + let document = &app.tabs[0].scene.document; + let part = document.block_records.get("Part").unwrap(); + let existing = part.entity_handles.iter().filter(|h| document.get_entity(**h).is_some()).count(); + assert_eq!(existing, 3, "undo removed the added member"); + } + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType; From 795aa922b400c30946ebc744da943edc6131c00a Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 17:05:48 +0200 Subject: [PATCH 54/76] feat(python): linetypes and layouts (doc.linetypes, doc.layouts) TableOperation gains simple-linetype create/modify/rename/delete (standard names taken, rename follows layers and entities, guarded delete) and paper-space layout create/rename/delete/set-current/set-page (default page setup and sheet viewport, the application's own layout switch, Model protected). CTAB now reports the current layout. Real-IPC audit with DWG/DXF persistence and undo, IPC and Python unit tests, docs. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 28 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 9 + docs/plugin-host-model-completion-plan.md | 13 + plugins/opencad-python/PLUGIN.md | 34 ++ plugins/opencad-python/src/document_model.py | 122 ++++ plugins/opencad-python/src/ocs_module.rs | 208 +++++++ .../tests/test_document_model.py | 26 + src/app/plugin_host.rs | 520 ++++++++++++++++++ 8 files changed, 960 insertions(+) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 1f8903987..9b1ff2ee3 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -147,6 +147,34 @@ pub enum TableOperation { /// 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 }, + /// Change a simple linetype's description or pattern in place. + LinetypeModify { name: String, description: Option, pattern: Option> }, + /// 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, + scale: Option<[f64; 2]>, + }, } /// The style tables `TableOperation` can rename, delete or make current. diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index aee1a13f2..967181cac 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -145,6 +145,15 @@ mod tests { 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::(&bytes).unwrap(), diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 0ca0d3740..8c870598d 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -240,3 +240,16 @@ already do. Evidence: `audit_python_block_contents_over_real_ipc` (9 refusals; c attribute definition inside a block; nested insert; live/DWG/DXF persistence; undo), IPC and Python unit tests. Next: linetypes and layouts, then headless commands. +### 2026-09-21: linetypes and layouts (`doc.linetypes`, `doc.layouts`) + +`TableOperation` gained `LinetypeCreate/Modify/Rename/Delete` (simple patterns only, standard names taken, rename +follows layers and entities, delete refused while a layer, entity, dimension style or the current setting uses it) and +`LayoutCreate/Rename/Delete/SetCurrent/SetPage` (reusing `add_layout`, the default page setup, the sheet viewport, +`rename_layout` plus the layout dictionary key, `delete_layout`, and the application's own `on_layout_switch`; `Model` is +protected; `set_page` sets paper size, rotation and a custom scale and refreshes the limits). The host system variable +`CTAB` now reports the current layout. Evidence: `audit_python_linetypes_and_layouts_over_real_ipc` (34 refusals; patterns, +modify, rename following layers; page setup; an entity created on a sheet and not in model space; live/DWG/DXF +persistence; deleting the current layout; four-step undo restoring the layouts, their entity and the linetype), IPC +round-trip and Python unit tests. Not covered: complex (text/shape) linetypes, reordering layouts, plot devices and +styles, and page-setup names. Next: headless command execution. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 4533d285e..00b94a712 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -224,6 +224,40 @@ block marker or nested attribute, an entity on a locked layer, and an insert tha would nest a block inside itself directly or through other blocks. Adding while the block editor is open is refused. Each add or delete is one undo step. +### Linetypes and layouts: `doc.linetypes`, `doc.layouts` + +```python +doc = ocs.active_document +LT, LY = doc.linetypes, doc.layouts +LT.create("Bracket", [12, -3, 2, -3], "bracket line") # dash 12, gap 3, dash 2, gap 3 +LT.modify("Bracket", description="renamed"); LT.rename("Bracket", "Cut") +doc.layers.create("Cuts", linetype="Cut") +LT.delete("Cut") # refused while a layer uses it + +LY.create("Sheet1") +LY.set_page("Sheet1", paper_size=(420, 297), rotation=0, scale=(1, 50)) +LY.current = "Sheet1" # create_entity now draws on the sheet +doc.create_entity("Circle", center={"x": 50, "y": 50, "z": 0}, radius=5) +LY.current = "Model" +LY.rename("Sheet1", "Plan"); LY.delete("Plan") # deletes the sheet's entities too +LY.names(), LY.current["name"], LY["Plan"]["paper_size"] +``` + +Both follow the same rules as the other tables: host-validated, one undo step +(switching layout is not one), a refusal raises `RuntimeError` and changes +nothing. A linetype pattern is signed lengths (positive dash, negative gap, zero +dot; 2-12 elements with a gap and a dash or dot); text and shape linetypes can be +read (`complex`) but not created or edited. `Continuous`, `ByLayer` and `ByBlock` +are never changed, names of standard linetypes are taken, renaming updates the +layers and entities that use it, and a linetype used by a layer, entity, dimension +style or the current setting is never deleted. Layouts: `Model` cannot be created, +renamed or deleted; deleting the current layout falls back to `Model`; a +case-only rename is refused. `set_page` takes the sheet in millimetres, a rotation +of 0/90/180/270 and a custom scale `(numerator, denominator)`, and updates the +layout limits. Layout operations act on the active document. Records list +`current`, `tab_order`, `entity_count`, `viewport_count`, `paper_size`, `rotation` +and `scale`. Reordering layouts and plot devices or styles are not available yet. + ### Historical command-replay experiment (not in the default build) The following records an earlier experiment. The bundled build does **not** diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 8c1894b88..64a7f9761 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -460,6 +460,126 @@ class _Blocks: ocs.block_operation("delete", str(name), None) +class _Named: + """Shared lookup for the linetype and layout collections.""" + + _RECORDS = None + + def _records(self): + return getattr(ocs, self._RECORDS)() + + def __iter__(self): + return iter(self._records()) + + def __len__(self): + return len(self._records()) + + def __contains__(self, name): + wanted = str(name).strip().upper() + return any(r["name"].upper() == wanted for r in self._records()) + + def __getitem__(self, name): + wanted = str(name).strip().upper() + for record in self._records(): + if record["name"].upper() == wanted: + return record + raise KeyError(name) + + def get(self, name, default=None): + try: + return self[name] + except KeyError: + return default + + def names(self): + return [r["name"] for r in self._records()] + + +class _Linetypes(_Named): + """`ocs.active_document.linetypes`. A pattern is signed lengths in drawing + units: positive is a dash, negative a gap, zero a dot (2-12 elements with at + least one gap and one dash or dot). Text and shape linetypes can be read + but not created or edited. Every change is validated by the host, is one + undo step, and a refusal raises `RuntimeError` without changing anything. + `Continuous`, `ByLayer` and `ByBlock` are never changed; a linetype used by + a layer, an entity, a dimension style or the current setting is never + deleted.""" + + _RECORDS = "linetype_records" + + def create(self, name, pattern, description=""): + ocs.linetype_operation("create", str(name), {"pattern": [float(v) for v in pattern], + "description": str(description)}) + return self[name] + + def modify(self, name, pattern=None, description=None): + options = {} + if pattern is not None: + options["pattern"] = [float(v) for v in pattern] + if description is not None: + options["description"] = str(description) + ocs.linetype_operation("modify", str(name), options) + return self[name] + + def rename(self, name, new_name): + ocs.linetype_operation("rename", str(name), {"to": str(new_name)}) + return self[new_name] + + def delete(self, name): + ocs.linetype_operation("delete", str(name), None) + + +class _Layouts(_Named): + """`ocs.active_document.layouts`: `Model` and the paper-space layouts in tab + order. Creation entities into a layout by making it current first + (`layouts.current = "Sheet1"`), then `create_entity(...)`. Every change is + validated by the host and is one undo step (switching layout is not); a + refusal raises `RuntimeError`. Layout operations act on the active + document.""" + + _RECORDS = "layout_records" + + @property + def current(self): + for record in self._records(): + if record["current"]: + return record + return None + + @current.setter + def current(self, name): + self.set_current(name) + + def create(self, name): + ocs.layout_operation("create", str(name), None) + return self[name] + + def rename(self, name, new_name): + ocs.layout_operation("rename", str(name), {"to": str(new_name)}) + return self[new_name] + + def delete(self, name): + """Delete a paper-space layout and everything on it (never `Model`).""" + ocs.layout_operation("delete", str(name), None) + + def set_current(self, name): + ocs.layout_operation("set_current", str(name), None) + return self[name] + + def set_page(self, name, paper_size=None, rotation=None, scale=None): + """`paper_size` is (width, height) in mm, `rotation` 0/90/180/270 degrees + and `scale` a custom plot scale (numerator, denominator).""" + options = {} + if paper_size is not None: + options["paper_size"] = [float(v) for v in paper_size] + if rotation is not None: + options["rotation"] = int(rotation) + if scale is not None: + options["scale"] = [float(v) for v in scale] + ocs.layout_operation("set_page", str(name), options) + return self[name] + + class _Document: def __init__(self): self._pending = None @@ -467,6 +587,8 @@ class _Document: self.text_styles = _TextStyles() self.dim_styles = _DimStyles() self.blocks = _Blocks() + self.linetypes = _Linetypes() + self.layouts = _Layouts() self.entities = _Entities(self) self.solids = _Solids(self) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 4e1667330..60c70fb46 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -1398,6 +1398,214 @@ mod ocs { Ok(vm.ctx.new_list(output).into()) } + /// Linetype change through the host. `op` is `create` or `modify` + /// (`description`, `pattern`: signed lengths, positive dash, negative gap, + /// zero dot), `rename` (`to`) or `delete`. Returns the linetype's handle. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn linetype_operation(op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::TableOperation; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let pattern = |value: PyObjectRef| -> PyResult> { + let items: Vec = value.try_into_value(vm)?; + items.into_iter().map(|item| py_number_to_f64(item, vm)).collect() + }; + let operation = match op.as_str() { + "create" => { + ensure_known_entity_keys(&options, "linetype", &["description", "pattern"], vm)?; + TableOperation::LinetypeCreate { + name, + description: get_opt_string(&options, "description", "", vm)?, + pattern: pattern(present("pattern")?.ok_or_else(|| vm.new_value_error("ocs: a linetype needs a pattern".to_owned()))?)?, + } + } + "modify" => { + ensure_known_entity_keys(&options, "linetype", &["description", "pattern"], vm)?; + TableOperation::LinetypeModify { + name, + description: present("description")?.map(|v| v.try_into_value::(vm)).transpose()?, + pattern: present("pattern")?.map(pattern).transpose()?, + } + } + "rename" => { + ensure_known_entity_keys(&options, "linetype rename", &["to"], vm)?; + let to = options + .get_item_opt("to", vm)? + .ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))? + .try_into_value::(vm)?; + TableOperation::LinetypeRename { from: name, to } + } + "delete" => { + ensure_known_entity_keys(&options, "linetype delete", &[], vm)?; + TableOperation::LinetypeDelete { name } + } + other => return Err(vm.new_value_error(format!("ocs.linetype_operation: unknown operation {other:?}"))), + }; + let result = host_ctx::with_host(|host| host.table_operation(operation)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.linetype_operation: {error}"))) + } + + /// Every linetype: pattern as signed lengths plus how many layers and + /// entities use it. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn linetype_records(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let mut uses = std::collections::HashMap::::new(); + for layer in document.layers.iter() { + *uses.entry(layer.line_type.to_uppercase()).or_default() += 1; + } + for entity in document.entities() { + *uses.entry(entity.common().linetype.to_uppercase()).or_default() += 1; + } + document + .line_types + .iter() + .map(|lt| (lt.clone(), uses.get(<.name.to_uppercase()).copied().unwrap_or(0))) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (lt, used) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(lt.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(lt.name.clone()), vm)?; + dict.set_item("description", vm.new_pyobj(lt.description.clone()), vm)?; + let pattern: Vec = lt.elements.iter().map(|e| vm.new_pyobj(e.length)).collect(); + dict.set_item("pattern", PyObjectRef::from(vm.ctx.new_list(pattern)), vm)?; + dict.set_item("pattern_length", vm.new_pyobj(lt.pattern_length), vm)?; + dict.set_item("complex", vm.new_pyobj(lt.elements.iter().any(|e| e.complex.is_some())), vm)?; + dict.set_item("used_by", vm.new_pyobj(used), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Layout change through the host. `op` is `create`, `rename` (`to`), + /// `delete`, `set_current` or `set_page` (`paper_size`: (width, height) mm, + /// `rotation`: 0/90/180/270, `scale`: (numerator, denominator)). + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn layout_operation(op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::TableOperation; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let operation = match op.as_str() { + "create" => TableOperation::LayoutCreate { name }, + "delete" => TableOperation::LayoutDelete { name }, + "set_current" => TableOperation::LayoutSetCurrent { name }, + "rename" => { + ensure_known_entity_keys(&options, "layout rename", &["to"], vm)?; + let to = options + .get_item_opt("to", vm)? + .ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))? + .try_into_value::(vm)?; + TableOperation::LayoutRename { from: name, to } + } + "set_page" => { + ensure_known_entity_keys(&options, "layout page", &["paper_size", "rotation", "scale"], vm)?; + TableOperation::LayoutSetPage { + name, + paper_size: present("paper_size")?.map(|v| py_to_f64_array::<2>(v, vm)).transpose()?, + rotation: present("rotation")? + .map(|v| -> PyResult { + let degrees = v.try_into_value::(vm)?; + u16::try_from(degrees).map_err(|_| vm.new_value_error("ocs: the rotation must be 0, 90, 180 or 270".to_owned())) + }) + .transpose()?, + scale: present("scale")?.map(|v| py_to_f64_array::<2>(v, vm)).transpose()?, + } + } + other => return Err(vm.new_value_error(format!("ocs.layout_operation: unknown operation {other:?}"))), + }; + let result = host_ctx::with_host(|host| host.table_operation(operation)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.layout_operation: {error}"))) + } + + /// `Model` and every paper-space layout in tab order with its page setup. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn layout_records(vm: &VirtualMachine) -> PyResult { + use acadrust::objects::ObjectType; + let rows = host_ctx::with_host(|host| { + let current = match host.system_variable("CTAB") { + Some(ocs_plugin_api::host::HostSettingValue::Text(name)) => name, + _ => "Model".to_owned(), + }; + let document = host.document(); + let mut owned = std::collections::HashMap::::new(); + let mut viewports = std::collections::HashMap::::new(); + for entity in document.entities() { + let owner = entity.common().owner_handle.value(); + *owned.entry(owner).or_default() += 1; + if matches!(entity, acadrust::entities::EntityType::Viewport(_)) { + *viewports.entry(owner).or_default() += 1; + } + } + let mut layouts: Vec<_> = document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Layout(layout) if !layout.block_record.is_null() => Some(layout.clone()), + _ => None, + }) + .collect(); + layouts.sort_by_key(|layout| (layout.name != "Model", layout.tab_order)); + layouts + .into_iter() + .map(|layout| { + let count = owned.get(&layout.block_record.value()).copied().unwrap_or(0); + let vps = viewports.get(&layout.block_record.value()).copied().unwrap_or(0); + let is_current = layout.name == current; + (layout, count, vps, is_current) + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (layout, count, vps, is_current) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(layout.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(layout.name.clone()), vm)?; + dict.set_item("current", vm.new_pyobj(is_current), vm)?; + dict.set_item("tab_order", vm.new_pyobj(i64::from(layout.tab_order)), vm)?; + dict.set_item("entity_count", vm.new_pyobj(count), vm)?; + dict.set_item("viewport_count", vm.new_pyobj(vps), vm)?; + dict.set_item("paper_size", PyObjectRef::from(vm.ctx.new_list(vec![vm.new_pyobj(layout.paper_width), vm.new_pyobj(layout.paper_height)])), vm)?; + dict.set_item("paper_name", vm.new_pyobj(layout.paper_size.clone()), vm)?; + let rotation = match layout.plot_rotation { + 1 => 90, + 2 => 180, + 3 => 270, + _ => 0, + }; + dict.set_item("rotation", vm.new_pyobj(rotation), vm)?; + dict.set_item("scale", PyObjectRef::from(vm.ctx.new_list(vec![vm.new_pyobj(layout.plot_scale_numerator), vm.new_pyobj(layout.plot_scale_denominator)])), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + /// Every layer with its full properties, in table order. #[cfg(feature = "experimental-host-model")] #[pyfunction] diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index 8e9f5ac3d..6149f617b 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -42,6 +42,10 @@ class DocumentModelTests(unittest.TestCase): block_records=lambda: [{"name": n, "entities": []} for n in ("Widget", "Gadget")], block_operation=lambda op, name, options: self.calls.append(("block", op, name, options)) or 9, add_to_block=lambda block, entity: self.calls.append(("add_to_block", block, entity)) or 1, + linetype_records=lambda: [{"name": n} for n in ("Continuous", "Bracket")], + linetype_operation=lambda op, name, options: self.calls.append(("linetype", op, name, options)) or 9, + layout_records=lambda: [{"name": n, "current": n == "Model"} for n in ("Model", "Sheet1")], + layout_operation=lambda op, name, options: self.calls.append(("layout", op, name, options)) or 9, ) namespace = {"ocs": self.ocs} exec(MODEL.read_text(), namespace) @@ -105,6 +109,28 @@ class DocumentModelTests(unittest.TestCase): self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, end={"x": 1, "y": 0, "z": 0}) self.assertEqual(self.calls[-1][0], "add") + def test_linetypes_and_layouts(self): + lt, ly = self.doc.linetypes, self.doc.layouts + self.assertIn("bracket", lt) + lt.create("Bracket", [12, -3], "test") + self.assertEqual(self.calls[-1], ("linetype", "create", "Bracket", {"pattern": [12.0, -3.0], "description": "test"})) + lt.modify("Bracket", pattern=[1, -1]) + self.assertEqual(self.calls[-1], ("linetype", "modify", "Bracket", {"pattern": [1.0, -1.0]})) + lt.rename("Bracket", "Bracket") + lt.delete("Bracket") + self.assertEqual(self.calls[-1], ("linetype", "delete", "Bracket", None)) + self.assertEqual(ly.current["name"], "Model") + ly.create("Sheet1") + ly.set_page("Sheet1", paper_size=(420, 297), rotation=0, scale=(1, 50)) + self.assertEqual(self.calls[-1], ("layout", "set_page", "Sheet1", + {"paper_size": [420.0, 297.0], "rotation": 0, "scale": [1.0, 50.0]})) + ly.current = "Sheet1" + self.assertEqual(self.calls[-1], ("layout", "set_current", "Sheet1", None)) + ly.delete("Sheet1") + self.assertEqual(self.calls[-1], ("layout", "delete", "Sheet1", None)) + with self.assertRaises(KeyError): + ly["Nope"] + def test_blocks(self): blocks = self.doc.blocks self.assertIn("WIDGET", blocks) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 2ae2d1172..2c6358e67 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -1018,6 +1018,7 @@ impl<'a> HostSession<'a> { self.document().header.current_layer_name.clone(), )), "SNAPANG" => Some(HostSettingValue::Number(self.app.snap_angle_deg as f64)), + "CTAB" => Some(HostSettingValue::Text(self.app.tabs[self.tab].scene.current_layout.clone())), _ => None, } } @@ -1259,10 +1260,377 @@ impl<'a> HostSession<'a> { | TableOperation::BlockRename { .. } | TableOperation::BlockDelete { .. } | TableOperation::BlockEntityAdd { .. }) => self.block_operation(other), + other @ (TableOperation::LinetypeCreate { .. } + | TableOperation::LinetypeModify { .. } + | TableOperation::LinetypeRename { .. } + | TableOperation::LinetypeDelete { .. }) => self.linetype_operation(other), + other @ (TableOperation::LayoutCreate { .. } + | TableOperation::LayoutRename { .. } + | TableOperation::LayoutDelete { .. } + | TableOperation::LayoutSetCurrent { .. } + | TableOperation::LayoutSetPage { .. }) => self.layout_operation(other), other => self.style_operation(other), } } + /// Simple linetype create/modify/rename/delete. + fn linetype_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { + use ocs_plugin_api::host::TableOperation; + let protected = |name: &str| { + ["CONTINUOUS", "BYLAYER", "BYBLOCK"].contains(&name.trim().to_uppercase().as_str()) + }; + let check_pattern = |pattern: &[f64]| -> Result<(), String> { + if !(2..=12).contains(&pattern.len()) { + return Err("a linetype pattern needs 2 to 12 elements".to_owned()); + } + if pattern.iter().any(|v| !v.is_finite() || v.abs() > 1.0e6) { + return Err("linetype pattern lengths must be finite and within 1e6".to_owned()); + } + if !pattern.iter().any(|v| *v >= 0.0) || !pattern.iter().any(|v| *v < 0.0) { + return Err("a linetype pattern needs at least one dash or dot and one gap".to_owned()); + } + if pattern.iter().map(|v| v.abs()).sum::() <= 0.0 { + return Err("a linetype pattern must have a nonzero length".to_owned()); + } + Ok(()) + }; + let build = |lt: &mut acadrust::tables::LineType, pattern: &[f64]| { + lt.elements = pattern + .iter() + .map(|length| acadrust::tables::linetype::LineTypeElement { length: *length, complex: None }) + .collect(); + lt.pattern_length = pattern.iter().map(|v| v.abs()).sum(); + }; + let check_description = |text: &str| { + if text.chars().any(|c| c.is_control()) || text.chars().count() > 255 { + Err("a linetype description must be text of at most 255 characters".to_owned()) + } else { + Ok(()) + } + }; + // Loading the standard linetypes first means a name that already exists as a + // standard pattern (Dashed, Center, ...) is seen as taken. + crate::io::linetypes::populate_document(self.document_mut()); + match operation { + TableOperation::LinetypeCreate { name, description, pattern } => { + let name = validated_symbol_name(&name, "a linetype name")?; + if self.document().line_types.contains(&name) { + return Err(format!("linetype {name:?} already exists")); + } + check_pattern(&pattern)?; + check_description(&description)?; + self.push_undo("Create linetype"); + let mut lt = acadrust::tables::LineType::new(name.clone()); + lt.description = description; + build(&mut lt, &pattern); + let handle = self.document_mut().allocate_handle(); + lt.handle = handle; + self.document_mut().line_types.add(lt).map_err(|e| format!("the linetype could not be created: {e}"))?; + self.finish_linetype_change(&[name]); + Ok(handle) + } + TableOperation::LinetypeModify { name, description, pattern } => { + let existing = self + .document() + .line_types + .get(name.trim()) + .cloned() + .ok_or_else(|| format!("linetype {:?} does not exist", name.trim()))?; + if protected(&existing.name) || existing.xref_dependent { + return Err(format!("linetype {:?} cannot be changed", existing.name)); + } + if existing.elements.iter().any(|e| e.complex.is_some()) { + return Err("a linetype with text or shape elements cannot be changed from a script".to_owned()); + } + if description.is_none() && pattern.is_none() { + return Err("no linetype properties to change".to_owned()); + } + if let Some(text) = &description { + check_description(text)?; + } + if let Some(pattern) = &pattern { + check_pattern(pattern)?; + } + self.push_undo("Modify linetype"); + let mut lt = existing.clone(); + if let Some(text) = description { + lt.description = text; + } + if let Some(pattern) = &pattern { + build(&mut lt, pattern); + } + if let Some(slot) = self.document_mut().line_types.get_mut(&existing.name) { + *slot = lt; + } + self.finish_linetype_change(&[existing.name.clone()]); + Ok(existing.handle) + } + TableOperation::LinetypeRename { from, to } => { + let existing = self + .document() + .line_types + .get(from.trim()) + .cloned() + .ok_or_else(|| format!("linetype {:?} does not exist", from.trim()))?; + if protected(&existing.name) || existing.xref_dependent { + return Err(format!("linetype {:?} cannot be renamed", existing.name)); + } + let to = validated_symbol_name(&to, "a linetype name")?; + if existing.name.eq_ignore_ascii_case(&to) { + return Err("the new linetype name matches the current one (a case-only change is not supported)".to_owned()); + } + if self.document().line_types.contains(&to) { + return Err(format!("linetype {to:?} already exists")); + } + self.push_undo("Rename linetype"); + let old = existing.name.clone(); + let handle = existing.handle; + let doc = self.document_mut(); + let mut lt = existing; + lt.name = to.clone(); + doc.line_types.remove(&old); + doc.line_types.add(lt).map_err(|e| format!("the linetype could not be renamed: {e}"))?; + for layer in doc.layers.iter_mut() { + if layer.line_type.eq_ignore_ascii_case(&old) { + layer.line_type = to.clone(); + } + } + for entity in doc.entities_mut() { + if entity.common().linetype.eq_ignore_ascii_case(&old) { + entity.common_mut().linetype = to.clone(); + } + } + if doc.header.current_linetype_name.eq_ignore_ascii_case(&old) { + doc.header.current_linetype_name = to.clone(); + } + self.finish_linetype_change(&[old, to]); + Ok(handle) + } + TableOperation::LinetypeDelete { name } => { + let existing = self + .document() + .line_types + .get(name.trim()) + .cloned() + .ok_or_else(|| format!("linetype {:?} does not exist", name.trim()))?; + if protected(&existing.name) || existing.xref_dependent { + return Err(format!("linetype {:?} cannot be deleted", existing.name)); + } + let doc = self.document(); + let in_use = doc.header.current_linetype_name.eq_ignore_ascii_case(&existing.name) + || doc.layers.iter().any(|layer| layer.line_type.eq_ignore_ascii_case(&existing.name)) + || doc.entities().any(|entity| entity.common().linetype.eq_ignore_ascii_case(&existing.name)) + || doc.dim_styles.iter().any(|style| { + [style.dimltex_handle, style.dimltex1_handle, style.dimltex2_handle].contains(&existing.handle) + }); + if in_use { + return Err(format!("linetype {:?} is current or still in use", existing.name)); + } + self.push_undo("Delete linetype"); + self.document_mut().line_types.remove(&existing.name); + self.finish_linetype_change(&[existing.name]); + Ok(existing.handle) + } + _ => Err("unsupported table operation".to_owned()), + } + } + + fn finish_linetype_change(&mut self, names: &[String]) { + self.app.tabs[self.tab].dirty = true; + self.app.tabs[self.tab].scene.invalidate_layer_dependencies(names); + self.app.tabs[self.tab].scene.bump_geometry(); + self.publish_document_view(); + } + + /// Paper-space layout operations (create, rename, delete, switch, page). + /// They act on the active document because switching layouts drives the + /// application's view state. + fn layout_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { + use acadrust::objects::ObjectType; + use ocs_plugin_api::host::TableOperation; + if self.tab != self.app.active_tab { + return Err("layout operations act on the active document; switch to it first".to_owned()); + } + let find = |doc: &CadDocument, name: &str| -> Result<(String, Handle), String> { + doc.objects + .values() + .find_map(|object| match object { + ObjectType::Layout(layout) + if !layout.block_record.is_null() && layout.name.eq_ignore_ascii_case(name) => + { + Some((layout.name.clone(), layout.handle)) + } + _ => None, + }) + .ok_or_else(|| format!("layout {name:?} does not exist")) + }; + let names_taken = |app: &crate::app::OpenCADStudio, tab: usize, name: &str| { + app.tabs[tab].scene.layout_names().iter().any(|existing| existing.eq_ignore_ascii_case(name)) + }; + match operation { + TableOperation::LayoutCreate { name } => { + let name = validated_symbol_name(&name, "a layout name")?; + if names_taken(self.app, self.tab, &name) { + return Err(format!("layout {name:?} already exists")); + } + self.push_undo("Create layout"); + let handle = self + .document_mut() + .add_layout(&name) + .map_err(|e| format!("the layout could not be created: {e}"))?; + let flags = i16::from(self.document().header.paper_space_linetype_scaling) + | (i16::from(self.document().header.paper_space_limit_check) << 1); + let plot_style = self + .app + .active_plot_style + .as_ref() + .map(|style| style.name.clone()) + .unwrap_or_default(); + for object in self.document_mut().objects.values_mut() { + if let ObjectType::Layout(layout) = object { + if layout.name == name { + layout.flags = flags; + crate::scene::apply_default_page_setup(layout, &plot_style); + break; + } + } + } + self.app.tabs[self.tab].scene.ensure_sheet_viewport(&name); + self.finish_layout_change(); + Ok(handle) + } + TableOperation::LayoutRename { from, to } => { + let (from, handle) = find(self.document(), from.trim())?; + if from.eq_ignore_ascii_case("Model") { + return Err("the Model layout cannot be renamed".to_owned()); + } + let to = validated_symbol_name(&to, "a layout name")?; + if to.eq_ignore_ascii_case("Model") { + return Err("a layout cannot be named Model".to_owned()); + } + if from.eq_ignore_ascii_case(&to) { + return Err("the new layout name matches the current one (a case-only change is not supported)".to_owned()); + } + if names_taken(self.app, self.tab, &to) { + return Err(format!("layout {to:?} already exists")); + } + self.push_undo("Rename layout"); + self.app.tabs[self.tab].scene.rename_layout(&from, &to); + // The layout dictionary is keyed by name; keep it in step. + let dict = self.document().header.acad_layout_dict_handle; + if let Some(ObjectType::Dictionary(dictionary)) = self.document_mut().objects.get_mut(&dict) { + for (key, _) in dictionary.entries.iter_mut() { + if *key == from { + *key = to.clone(); + } + } + } + if self.app.tabs[self.tab].scene.current_layout == from { + self.app.tabs[self.tab].scene.set_current_layout(to); + } + self.finish_layout_change(); + Ok(handle) + } + TableOperation::LayoutDelete { name } => { + let (name, handle) = find(self.document(), name.trim())?; + if name.eq_ignore_ascii_case("Model") { + return Err("the Model layout cannot be deleted".to_owned()); + } + self.push_undo("Delete layout"); + if self.app.tabs[self.tab].scene.current_layout == name { + let _ = self.app.on_layout_switch("Model".to_owned()); + } + if !self.app.tabs[self.tab].scene.delete_layout(&name) { + return Err("the layout could not be deleted".to_owned()); + } + self.finish_layout_change(); + Ok(handle) + } + TableOperation::LayoutSetCurrent { name } => { + let name = name.trim(); + if name.eq_ignore_ascii_case("Model") { + let _ = self.app.on_layout_switch("Model".to_owned()); + return Ok(Handle::NULL); + } + let (name, handle) = find(self.document(), name)?; + let _ = self.app.on_layout_switch(name); + self.publish_document_view(); + Ok(handle) + } + TableOperation::LayoutSetPage { name, paper_size, rotation, scale } => { + let (name, handle) = find(self.document(), name.trim())?; + if name.eq_ignore_ascii_case("Model") { + return Err("the Model layout has no sheet setup".to_owned()); + } + if paper_size.is_none() && rotation.is_none() && scale.is_none() { + return Err("no page setup properties to change".to_owned()); + } + if let Some([width, height]) = paper_size { + if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 && width <= 100_000.0 && height <= 100_000.0) { + return Err("the paper size must be positive and at most 100000 mm each way".to_owned()); + } + } + let rotation_code = match rotation { + None => None, + Some(0) => Some(acadrust::objects::PlotRotation::None), + Some(90) => Some(acadrust::objects::PlotRotation::Degrees90), + Some(180) => Some(acadrust::objects::PlotRotation::Degrees180), + Some(270) => Some(acadrust::objects::PlotRotation::Degrees270), + Some(_) => return Err("the plot rotation must be 0, 90, 180 or 270 degrees".to_owned()), + }; + if let Some([numerator, denominator]) = scale { + if !(numerator.is_finite() && denominator.is_finite() && numerator > 0.0 && denominator > 0.0) { + return Err("the plot scale numerator and denominator must be positive".to_owned()); + } + } + self.push_undo("Layout page setup"); + for object in self.document_mut().objects.values_mut() { + let ObjectType::Layout(layout) = object else { continue }; + if layout.name != name { + continue; + } + if let Some([width, height]) = paper_size { + layout.paper_width = width; + layout.paper_height = height; + layout.paper_size = String::new(); + } + if let Some(code) = rotation_code { + layout.plot_rotation = code.to_code(); + } + if let Some([numerator, denominator]) = scale { + layout.plot_scale_numerator = numerator; + layout.plot_scale_denominator = denominator; + layout.plot_scale_factor = numerator / denominator; + layout.plot_scale_type = acadrust::objects::ScaledType::CustomScale.to_code(); + layout.plot_flags.use_standard_scale = false; + } + let quarter_turn = layout.plot_rotation == acadrust::objects::PlotRotation::Degrees90.to_code() + || layout.plot_rotation == acadrust::objects::PlotRotation::Degrees270.to_code(); + let (x, y) = if quarter_turn { + (layout.paper_height, layout.paper_width) + } else { + (layout.paper_width, layout.paper_height) + }; + layout.min_limits = (0.0, 0.0); + layout.max_limits = (x, y); + layout.min_extents = (0.0, 0.0, 0.0); + layout.max_extents = (x, y, 0.0); + break; + } + self.app.tabs[self.tab].scene.invalidate_display_plot_style(); + self.finish_layout_change(); + Ok(handle) + } + _ => Err("unsupported table operation".to_owned()), + } + } + + fn finish_layout_change(&mut self) { + self.app.tabs[self.tab].dirty = true; + self.app.tabs[self.tab].scene.bump_geometry(); + self.publish_document_view(); + } + /// Block definition operations: create from entities, modify settings, /// rename (inserts follow) and delete an unreferenced definition. fn block_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { @@ -7310,6 +7678,158 @@ check('unknown_insert_block', lambda: doc.create_entity('Insert', block_name='Gh assert_eq!(existing, 3, "undo removed the added member"); } + /// Linetypes and layouts through Python over the real runner: create, + /// modify, rename (layers follow), guarded delete, page setup, entities + /// placed on a layout, DWG/DXF persistence and undo. + #[test] + fn audit_python_linetypes_and_layouts_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_ltlay_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\n", + "doc = ocs.active_document\nLT = doc.linetypes\nLY = doc.layouts\nL = doc.layers\n", + "refused, accepted = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n accepted.append(tag)\n", + "try:\n{body}\n", + "except Exception as error:\n", + " accepted.append('SCRIPTERROR_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:120])\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(accepted))\n", + ), body = body.lines().map(|line| format!(" {line}")).collect::>().join("\n"))).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, accepted) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), accepted.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +LT.create('Bracket', [12, -3, 2, -3], 'Phantom test') +LT.create('Dots', [0, -2], 'dots') +LT.modify('Dots', pattern=[0, -4], description='sparse dots') +LT.create('Temp', [1, -1]) +L.create('Hidden2', linetype='Bracket') +L.create('Dotted2', linetype='Dots') +LT.rename('Dots', 'Sparse') +LY.create('Sheet1') +LY.create('Sheet2') +LY.set_page('Sheet1', paper_size=(420, 297), rotation=0, scale=(1, 50)) +LY.rename('Sheet2', 'Details') +LY.set_current('Sheet1') +doc.create_entity('Circle', center=P(50, 50, 0), radius=5) +LY.set_current('Model') +check('lt_dup', lambda: LT.create('bracket', [1, -1])) +check('lt_standard_dup', lambda: LT.create('Dashed', [1, -1])) +check('lt_badname', lambda: LT.create('a|b', [1, -1])) +check('lt_empty', lambda: LT.create('X1', [])) +check('lt_single', lambda: LT.create('X2', [1])) +check('lt_nogap', lambda: LT.create('X3', [1, 2])) +check('lt_nodash', lambda: LT.create('X4', [-1, -2])) +check('lt_nonfinite', lambda: LT.create('X5', [1e999, -1])) +check('lt_toomany', lambda: LT.create('X6', [1, -1] * 7)) +check('lt_modnone', lambda: LT.modify('Bracket')) +check('lt_modmissing', lambda: LT.modify('Nope', description='x')) +check('lt_modcontinuous', lambda: LT.modify('Continuous', description='x')) +check('lt_renamecontinuous', lambda: LT.rename('Continuous', 'Solid2')) +check('lt_renameclash', lambda: LT.rename('Temp', 'BRACKET')) +check('lt_renamecase', lambda: LT.rename('Temp', 'TEMP')) +check('lt_delcontinuous', lambda: LT.delete('ByLayer')) +check('lt_delinuse', lambda: LT.delete('Bracket')) +check('lt_delmissing', lambda: LT.delete('Nope')) +check('ly_dup', lambda: LY.create('sheet1')) +check('ly_model', lambda: LY.create('Model')) +check('ly_badname', lambda: LY.create('a = phantom.elements.iter().map(|e| e.length).collect(); + assert_eq!(lengths, vec![12.0, -3.0, 2.0, -3.0], "{label}: pattern"); + assert_eq!(phantom.pattern_length, 20.0, "{label}"); + let sparse = document.line_types.get("Sparse").unwrap_or_else(|| panic!("{label}: Sparse missing")); + assert_eq!(sparse.elements.iter().map(|e| e.length).collect::>(), vec![0.0, -4.0], "{label}: modified pattern"); + assert_eq!(document.layers.get("Dotted2").unwrap().line_type, "Sparse", "{label}: layer followed the rename"); + assert_eq!(document.layers.get("Hidden2").unwrap().line_type, "Bracket", "{label}"); + let layouts = |name: &str| document.objects.values().find_map(|o| match o { + acadrust::objects::ObjectType::Layout(l) if l.name == name && !l.block_record.is_null() => Some(l.clone()), + _ => None, + }); + assert!(layouts("Sheet2").is_none(), "{label}: old layout name gone"); + let sheet = layouts("Sheet1").unwrap_or_else(|| panic!("{label}: Sheet1 missing")); + assert_eq!((sheet.paper_width, sheet.paper_height), (420.0, 297.0), "{label}: paper size"); + assert_eq!(sheet.plot_rotation, 0, "{label}: rotation"); + assert_eq!((sheet.plot_scale_numerator, sheet.plot_scale_denominator), (1.0, 50.0), "{label}: scale"); + assert!(layouts("Details").is_some(), "{label}: Details missing"); + let on_sheet = document.entities().filter(|e| e.common().owner_handle == sheet.block_record && matches!(e, EntityType::Circle(_))).count(); + assert_eq!(on_sheet, 1, "{label}: the circle was created on the sheet"); + assert!(!document.entities().any(|e| matches!(e, EntityType::Circle(_)) && document.block_records.iter().any(|r| r.name == "*Model_Space" && r.handle == e.common().owner_handle)), + "{label}: no circle in model space"); + }; + check_state("live", host.document()); + assert_eq!(host.app.tabs[0].scene.current_layout, "Model", "the script switched back to Model"); + let dwg = crate::io::load_bytes("ltl.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("ltl.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + run(&mut host, "destroy", r#" +LY.set_current('Details') +LY.delete('Details') +LY.delete('Sheet1') +LT.delete('Temp') +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "{accepted:?}"); + assert_eq!(refused, 0); + let document = host.document(); + assert!(document.line_types.get("Temp").is_none()); + assert!(!document.objects.values().any(|o| matches!(o, acadrust::objects::ObjectType::Layout(l) if l.name == "Sheet1" || l.name == "Details"))); + assert!(!document.entities().any(|e| matches!(e, EntityType::Circle(_))), "the sheet's circle went with its layout"); + assert_eq!(host.app.tabs[0].scene.current_layout, "Model", "deleting the current layout fell back to Model"); + + // U: the report layer and the three deletes; undoing them restores the layouts and the linetype. + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(4); + let document = &app.tabs[0].scene.document; + assert!(document.line_types.get("Temp").is_some(), "undo restored the linetype"); + let names = app.tabs[0].scene.layout_names(); + assert!(names.contains(&"Sheet1".to_owned()) && names.contains(&"Details".to_owned()), "undo restored the layouts: {names:?}"); + assert!(document.entities().any(|e| matches!(e, EntityType::Circle(_))), "undo restored the sheet's circle"); + } + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType; From 1b1785eefbe8004abf00cd2dcec68aa01a2e3244 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 17:25:05 +0200 Subject: [PATCH 55/76] feat(python): command runner and modify wrappers (doc.command, doc.modify) HostApi::run_command drives the real OCS command through the same primitives the automation channel uses, synchronously on the host thread: run a line, start a command and answer point/text/token/entity/selection/enter prompts, or cancel (closing anything the command opened). Refused while another command is active, for a denylist (QUIT, NEW, OPEN, SAVE*, PY_* ...) and off the active tab. Python: doc.command, doc.start_command and doc.modify (offset, trim, extend, fillet, move, copy, rotate, scale, mirror, erase). Real-IPC audit checks exact geometry for every wrapper plus guards and undo; IPC and Python unit tests; docs. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 57 +++ crates/ocs_plugin_api/src/ipc/client.rs | 12 + crates/ocs_plugin_api/src/ipc/protocol.rs | 3 + crates/ocs_plugin_api/src/ipc/server.rs | 1 + crates/ocs_plugin_api/src/ipc/v4/client.rs | 12 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 37 ++ docs/plugin-host-model-completion-plan.md | 20 + plugins/opencad-python/PLUGIN.md | 48 +++ plugins/opencad-python/src/document_model.py | 199 +++++++++ plugins/opencad-python/src/ocs_module.rs | 54 +++ .../tests/test_document_model.py | 47 +++ src/app/control/mod.rs | 2 +- src/app/plugin_host.rs | 376 ++++++++++++++++++ 13 files changed, 867 insertions(+), 1 deletion(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 9b1ff2ee3..bcdb88cda 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -85,6 +85,56 @@ pub enum SolidBoolean { 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:`. + pub blocked_by: Option, + /// 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, + pub options: Vec, + /// 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, + /// The command line's error message when the step failed. + pub error: Option, +} + /// 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. @@ -808,6 +858,13 @@ pub trait HostApi { 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 { + 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 { diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index c4dba1ac8..21cceff54 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -482,6 +482,18 @@ impl HostApi for PluginHostApi { } } + fn run_command(&mut self, request: crate::host::CommandRequest) -> Result { + 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 { match self.client.request(PluginRequest::TableOperation { operation }) { Ok(PluginResponse::TableResult(result)) => { diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index a69d501ca..ee6bd92ec 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -157,6 +157,8 @@ pub enum PluginRequest { 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`. @@ -192,6 +194,7 @@ pub enum PluginResponse { SelectionResult(Result<(), String>), SolidResult(Result), TableResult(Result), + CommandResult(Result), } /// Messages sent from the host to the plugin runner. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 46aab7f84..a2d3c4b64 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -111,5 +111,6 @@ pub fn handle_plugin_request( 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)), } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 2ab6c7c66..d6da46bbe 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -796,6 +796,18 @@ impl HostApi for V4PluginHostApi { } } + fn run_command(&mut self, request: crate::host::CommandRequest) -> Result { + 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 { match self.request(PluginRequest::TableOperation { operation }) { Ok(PluginResponse::TableResult(result)) => { diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 967181cac..bbf969f41 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -105,6 +105,43 @@ mod tests { 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::(&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::(&bytes).unwrap(), + PluginResponse::CommandResult(decoded) if decoded == result)); + } + } + #[test] fn table_operation_request_and_response_roundtrip() { use crate::host::{LayerConfig, TableOperation}; diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 8c870598d..e9fb5aac2 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -253,3 +253,23 @@ persistence; deleting the current layout; four-step undo restoring the layouts, round-trip and Python unit tests. Not covered: complex (text/shape) linetypes, reordering layouts, plot devices and styles, and page-setup names. Next: headless command execution. +### 2026-09-21: command runner and modify wrappers (`doc.command`, `doc.start_command`, `doc.modify`) + +`HostApi::run_command(CommandRequest) -> CommandOutcome` (additive v7; `Run`, `Start`, `Point`, `Text`, `Token`, `Entity`, +`Selection`, `Enter`, `Cancel`) drives the real command through the same primitives the automation channel uses +(`run_command_line`, `dispatch_command`, `feed_command`, `feed_active_cmd`, `CommandEscape`) and finishes each step with +`drive_headless_task`, which is synchronous on the host thread. The earlier "nested command replay can hang" concern does not +apply to this path: no step waits on anything that must arrive from outside, the exploration harness ran every command +tried without a hang, and an editor or dialog a command opens is reported in `blocked_by` and closed by `Cancel`. It refuses +while another command is active, on a denylist (`QUIT`, `EXIT`, `CLOSE*`, `NEW`, `QNEW`, `OPEN`, `SAVE*`, `RECOVER`, `SCRIPT`, +`RUNSCRIPT`, `PY_*`), and off the active tab. It does not consult the user's automation on/off switch, which governs the +external MCP/serve channel; a script is already something the user chose to run. Python: `doc.command`, `doc.start_command` +(context manager that cancels a waiting command) and `doc.modify` (offset, trim, extend, fillet, move, copy, rotate, scale, +mirror, erase). The prompt sequences were observed with `spike_command_runner_prompts` (ignored harness). Evidence: +`audit_python_modify_wrappers_over_real_ipc` checks exact geometry for every wrapper (offset distance and side, trim and +extend end points, fillet arc centre and radius, move, copy keeping the original, rotate, scale, mirror keeping the source, +erase), `doc.command` and an interactive session, nine refusals (quit, new, `PY_RUN`, unknown, incomplete line, input with no +command, second command, run while active), no command left running, and a one-step undo. IPC round-trip and Python unit tests. +Not covered: chamfer, arrays, hatch, join, explode, break and the many other commands (reachable through `doc.command` and +`start_command`); reading prompts for commands with a text editor; an on/off setting for script-driven commands. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 00b94a712..04e6b1b8c 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -258,6 +258,54 @@ layout limits. Layout operations act on the active document. Records list `current`, `tab_order`, `entity_count`, `viewport_count`, `paper_size`, `rotation` and `scale`. Reordering layouts and plot devices or styles are not available yet. +### Running OCS commands: `doc.command`, `doc.start_command`, `doc.modify` + +Scripts can drive OCS's own tools. The host runs the real command, so it behaves +exactly as at the command line (same prompts, same geometry, same undo), and does +it synchronously on the editor thread, so a step never waits on outside input. + +```python +doc = ocs.active_document +doc.command("CIRCLE 5,5 3") # a whole line: tokens answer the prompts, then Enter +with doc.start_command("OFFSET") as c: # or answer prompt by prompt + c.text(2) # a typed value + c.entity(line, at=(5, 0, 0)) # an object pick + c.point((5, 4, 0)) # a point + c.enter() + print(c.outcome["status"], c.outcome["prompt"], c.outcome["added"]) + +M = doc.modify # the common tools as plain calls +M.offset(line, 2, side=(5, 5)) # returns the new entity +M.trim(target, at=(108, 0)); M.extend(target, at=(208, 0)) +M.fillet(first, second, radius=2) # radius 0 squares the corner +M.move([a, b], (0, 0, 0), (5, 5, 0)); M.copy([a], base, target) +M.rotate([a], base, 90); M.scale([a], base, 2) +M.mirror([a], p1, p2, erase_source=False); M.erase([a]) +``` + +Each step returns an outcome dict: `status` (`completed` or `waiting_input`), +`blocked_by`, `command`, `prompt`, `accepts` (`point`, `entity`, `selection`, +`text`, `token`, `enter`), `options` (keyword tokens), `entities`, `added`, +`unconsumed` and `error`. `c.point`, `c.text`, `c.token`, `c.entity`, +`c.selection`, `c.enter` and `c.cancel` map to those prompts; an `error` raises +`RuntimeError`, and leaving the `with` block cancels a command that is still waiting, +also closing any editor or dialog it opened. `doc.command` cancels and raises when +the line leaves a prompt open. + +Selection-based tools (`move`, `copy`, `rotate`, `scale`, `mirror`, `erase`) select +the entities you pass first, so the command skips its own selection prompt. Pick +points default to the middle of a line, arc or circle; give `at=` for anything else. +`trim` and `extend` cut or extend against every entity in the drawing. Each command is +undone as the editor would undo it. + +Guards: a command is refused while another is active (cancel it first) or when the +line names a command that could end the session or re-enter Python: `QUIT`, +`EXIT`, `CLOSE`, `CLOSEALL`, `NEW`, `QNEW`, `OPEN`, `SAVE`, `QSAVE`, `SAVEAS`, `SAVEALL`, +`RECOVER`, `SCRIPT`, `RUNSCRIPT` and any `PY_*`. Commands run on the active +document only. Commands that need an interactive surface a script cannot fill +(the rich-text editor, a file dialog) report it in `blocked_by` and are cancelled. +`ocs.command_step(kind, options)` is the raw single step behind all of this. + ### Historical command-replay experiment (not in the default build) The following records an earlier experiment. The bundled build does **not** diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 64a7f9761..703352752 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -580,6 +580,185 @@ class _Layouts(_Named): return self[name] +def _pt(value): + """A point as [x, y, z] from a tuple, list or {"x", "y", "z"} dict.""" + if isinstance(value, dict): + return [float(value["x"]), float(value["y"]), float(value.get("z", 0.0))] + coords = [float(v) for v in value] + if len(coords) == 2: + coords.append(0.0) + if len(coords) != 3: + raise ValueError("a point needs 2 or 3 coordinates") + return coords + + +def _handle(value): + return value.handle if isinstance(value, _Entity) else int(value) + + +class _Command: + """One running OCS command, driven a step at a time: + + with doc.start_command("OFFSET") as c: + c.text(2); c.entity(line, at=(5, 0, 0)); c.point((5, 4, 0)); c.enter() + + Each step returns the outcome dict (`status`, `prompt`, `accepts`, + `options`, `added`, `error`, ...) and raises `RuntimeError` when the command + reports an error. Leaving the `with` block cancels the command if it is still + waiting. The host runs the real command, refuses to start one while another + is active, and refuses commands that could end the session (QUIT, NEW, OPEN, + SAVE...) or re-enter Python (PY_*).""" + + def __init__(self, name): + self.name = str(name) + self.outcome = None + self._send("start", name=self.name) + + def _send(self, kind, **options): + outcome = ocs.command_step(kind, options) + self.outcome = outcome + if outcome["error"]: + raise RuntimeError("%s: %s" % (self.name, outcome["error"])) + return outcome + + @property + def waiting(self): + return self.outcome is not None and self.outcome["status"] == "waiting_input" + + def point(self, point): + return self._send("point", point=_pt(point)) + + def text(self, value): + return self._send("text", text=str(value)) + + def token(self, keyword): + return self._send("token", text=str(keyword)) + + def entity(self, entity, at): + return self._send("entity", handle=_handle(entity), point=_pt(at)) + + def selection(self): + """Complete an object-selection prompt with the current selection.""" + return self._send("selection") + + def enter(self): + return self._send("enter") + + def cancel(self): + return self._send("cancel") + + def __enter__(self): + return self + + def __exit__(self, *exc): + if self.waiting: + ocs.command_step("cancel", None) + return False + + +def _finished(command, what): + """Require that `command` ended by itself; cancel and raise if it did not.""" + if command.waiting: + prompt = command.outcome["prompt"] + command.cancel() + raise RuntimeError("%s did not finish: still asking %r" % (what, prompt)) + + +class _Modify: + """Modify tools as plain calls. Each drives the real OCS command, so it + behaves exactly as at the command line, and returns the entities it added. + A tool that needs a pick point takes `at=` (a point on or near the entity); + without it the middle of a line, arc or circle is used.""" + + def __init__(self, document): + self._document = document + + def _at(self, entity, at): + if at is not None: + return _pt(at) + samples = ocs.curve_samples(_handle(entity), 4) + if samples is None: + raise ValueError("pass at=(x, y, z): the middle of this kind of entity is not known") + middle = samples["vertices"][len(samples["vertices"]) // 2] + return [middle[0], middle[1], samples["elevation"]] + + def _select(self, entities): + ocs.select([_handle(e) for e in entities]) + + def _added(self, before): + before = set(before) + return [self._document.entities[h] for h in ocs.entity_handles() if h not in before] + + def _run(self, name, steps, entities=None): + before = list(ocs.entity_handles()) + if entities is not None: + self._select(entities) + with _Command(name) as command: + for step in steps: + step(command) + _finished(command, name) + return self._added(before) + + def offset(self, entity, distance, side, at=None): + """Offset one entity by `distance` towards the point `side`; returns the new entity.""" + result = self._run("OFFSET", [ + lambda c: c.text(distance), + lambda c: c.entity(entity, self._at(entity, at)), + lambda c: c.point(side), + lambda c: c.enter(), + ]) + if not result: + raise RuntimeError("OFFSET produced nothing") + return result[0] + + def trim(self, target, at): + """Trim `target` at the crossing nearest the click point `at`, against every entity in the drawing.""" + return self._run("TRIM", [lambda c: c.entity(target, at), lambda c: c.enter()]) + + def extend(self, target, at): + """Extend `target` from the end nearest `at` to the next entity in its way.""" + return self._run("EXTEND", [lambda c: c.entity(target, at), lambda c: c.enter()]) + + def fillet(self, first, second, radius=0, at1=None, at2=None): + """Round (or, with radius 0, square) the corner between two entities.""" + return self._run("FILLET", [ + lambda c: c.token("R"), + lambda c: c.text(radius), + lambda c: c.entity(first, self._at(first, at1)), + lambda c: c.entity(second, self._at(second, at2)), + lambda c: c.enter(), + ]) + + def move(self, entities, base, target): + self._run("MOVE", [lambda c: c.point(base), lambda c: c.point(target)], entities) + + def copy(self, entities, base, target): + """Copy the entities once from `base` to `target`; returns the copies.""" + return self._run("COPY", [lambda c: c.point(base), lambda c: c.point(target), lambda c: c.enter()], entities) + + def rotate(self, entities, base, angle): + """Rotate about `base` by `angle` degrees.""" + self._run("ROTATE", [lambda c: c.point(base), lambda c: c.text(angle)], entities) + + def scale(self, entities, base, factor): + self._run("SCALE", [lambda c: c.point(base), lambda c: c.text(factor)], entities) + + def mirror(self, entities, first, second, erase_source=False): + """Mirror about the line through two points; returns the mirrored copies.""" + return self._run("MIRROR", [ + lambda c: c.point(first), + lambda c: c.point(second), + lambda c: c.token("Y" if erase_source else "N"), + ], entities) + + def erase(self, entities): + before = len(list(ocs.entity_handles())) + self._select(entities) + with _Command("ERASE") as command: + _finished(command, "ERASE") + return before - len(list(ocs.entity_handles())) + + class _Document: def __init__(self): self._pending = None @@ -589,9 +768,29 @@ class _Document: self.blocks = _Blocks() self.linetypes = _Linetypes() self.layouts = _Layouts() + self.modify = _Modify(self) self.entities = _Entities(self) self.solids = _Solids(self) + def command(self, line): + """Run one whole OCS command line, e.g. `"CIRCLE 5,5 3"`; the tokens after + the name answer the prompts in order and a final Enter finishes. Returns + the outcome dict. If the line leaves the command waiting for more input the + command is cancelled and `RuntimeError` names the prompt; use + `start_command` to answer prompts one at a time.""" + outcome = ocs.command_step("run", {"line": str(line)}) + if outcome["error"]: + raise RuntimeError("%s: %s" % (line, outcome["error"])) + if outcome["status"] != "completed": + prompt = outcome["prompt"] or outcome["blocked_by"] + ocs.command_step("cancel", None) + raise RuntimeError("%r did not finish: still asking %r" % (line, prompt)) + return outcome + + def start_command(self, name): + """Start a command and answer its prompts step by step (see `_Command`).""" + return _Command(name) + def transaction(self, label): return _Transaction(self, label) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 60c70fb46..6a953f8f1 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -1606,6 +1606,60 @@ mod ocs { Ok(vm.ctx.new_list(output).into()) } + /// One step of driving a real OCS command. `kind` is `run` (`line`), + /// `start` (`name`), `point` (`point`), `text` (`text`), `token` (`text`), + /// `entity` (`handle`, `point`), `selection`, `enter` or `cancel`. Returns + /// where the command stands: `status`, `blocked_by`, `command`, `prompt`, + /// `accepts`, `options`, `entities`, `added`, `unconsumed`, `error`. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn command_step(kind: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::CommandRequest as R; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let required = |key: &str| -> PyResult { + options + .get_item_opt(key, vm)? + .ok_or_else(|| vm.new_value_error(format!("ocs.command_step: {kind} needs {key}"))) + }; + let request = match kind.as_str() { + "run" => R::Run { line: required("line")?.try_into_value::(vm)? }, + "start" => R::Start { name: required("name")?.try_into_value::(vm)? }, + "point" => R::Point { point: py_to_f64_array::<3>(required("point")?, vm)? }, + "text" => R::Text { text: required("text")?.try_into_value::(vm)? }, + "token" => R::Token { text: required("text")?.try_into_value::(vm)? }, + "entity" => R::Entity { + handle: Handle::new(required("handle")?.try_into_value::(vm)?), + point: py_to_f64_array::<3>(required("point")?, vm)?, + }, + "selection" => R::Selection, + "enter" => R::Enter, + "cancel" => R::Cancel, + other => return Err(vm.new_value_error(format!("ocs.command_step: unknown step {other:?}"))), + }; + let outcome = host_ctx::with_host(|host| host.run_command(request)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))? + .map_err(|error| vm.new_runtime_error(format!("ocs.command_step: {error}")))?; + let dict = vm.ctx.new_dict(); + let strings = |items: &[String]| -> PyObjectRef { + PyObjectRef::from(vm.ctx.new_list(items.iter().map(|v| vm.new_pyobj(v.clone())).collect())) + }; + dict.set_item("status", vm.new_pyobj(outcome.status.clone()), vm)?; + dict.set_item("blocked_by", outcome.blocked_by.clone().map_or_else(|| vm.ctx.none(), |v| vm.new_pyobj(v)), vm)?; + dict.set_item("command", vm.new_pyobj(outcome.command.clone()), vm)?; + dict.set_item("prompt", vm.new_pyobj(outcome.prompt.clone()), vm)?; + dict.set_item("accepts", strings(&outcome.accepts), vm)?; + dict.set_item("options", strings(&outcome.options), vm)?; + dict.set_item("entities", vm.new_pyobj(outcome.entities), vm)?; + dict.set_item("added", vm.new_pyobj(outcome.added), vm)?; + dict.set_item("unconsumed", strings(&outcome.unconsumed), vm)?; + dict.set_item("error", outcome.error.clone().map_or_else(|| vm.ctx.none(), |v| vm.new_pyobj(v)), vm)?; + Ok(dict.into()) + } + /// Every layer with its full properties, in table order. #[cfg(feature = "experimental-host-model")] #[pyfunction] diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index 6149f617b..ada10cfe5 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -46,11 +46,58 @@ class DocumentModelTests(unittest.TestCase): linetype_operation=lambda op, name, options: self.calls.append(("linetype", op, name, options)) or 9, layout_records=lambda: [{"name": n, "current": n == "Model"} for n in ("Model", "Sheet1")], layout_operation=lambda op, name, options: self.calls.append(("layout", op, name, options)) or 9, + command_step=self._command_step, + curve_samples=lambda handle, n: {"vertices": [[0, 0], [1, 0], [2, 0]], "elevation": 5.0, "closed": False}, ) namespace = {"ocs": self.ocs} exec(MODEL.read_text(), namespace) self.doc = self.ocs.active_document + def _command_step(self, kind, options): + self.calls.append(("step", kind, options)) + outcome = {"status": "completed", "blocked_by": None, "command": "", "prompt": "", "accepts": [], + "options": [], "entities": 0, "added": 0, "unconsumed": [], "error": None} + outcome.update(self.step_outcome.get(kind, {})) + if kind == "enter": + new = max(self.records) + 1 + self.records[new] = {"handle": new, "kind": "Line", "layer": "0", "_editable": True} + return outcome + + def test_command_runner(self): + self.step_outcome = {} + out = self.doc.command("CIRCLE 0,0 5") + self.assertEqual(self.calls[-1], ("step", "run", {"line": "CIRCLE 0,0 5"})) + self.assertEqual(out["status"], "completed") + self.step_outcome = {"run": {"status": "waiting_input", "prompt": "Select object"}} + with self.assertRaises(RuntimeError): + self.doc.command("OFFSET") + self.assertEqual(self.calls[-1], ("step", "cancel", None)) + self.step_outcome = {"run": {"error": "bad"}} + with self.assertRaises(RuntimeError): + self.doc.command("X") + waiting = {"status": "waiting_input"} + self.step_outcome = {"start": waiting, "point": waiting, "entity": waiting} + with self.doc.start_command("OFFSET") as command: + command.point((1, 2)) + self.assertEqual(self.calls[-1], ("step", "point", {"point": [1.0, 2.0, 0.0]})) + command.entity(self.doc.entities[1], at=(0, 0, 0)) + self.assertEqual(self.calls[-1][2]["handle"], 1) + self.assertEqual(self.calls[-1], ("step", "cancel", None)) + + def test_modify_wrappers(self): + self.step_outcome = {} + self.doc.modify.trim(1, (3, 0, 0)) + steps = [c for c in self.calls if c[0] == "step"] + self.assertEqual([c[1] for c in steps], ["start", "entity", "enter"]) + self.calls.clear() + self.doc.modify.move([1, 2], (0, 0, 0), (5, 5, 0)) + self.assertEqual(self.calls[0], ("selection", [1, 2])) + self.assertEqual([c[1] for c in self.calls if c[0] == "step"], ["start", "point", "point"]) + self.calls.clear() + self.doc.modify.offset(1, 2, (0, 5)) + entity_step = [c for c in self.calls if c[0] == "step" and c[1] == "entity"][0] + self.assertEqual(entity_step[2]["point"], [1, 0, 5.0]) + def test_batch_edit_and_rollback(self): line = self.doc.entities[1] self.assertEqual(line.start, (1, 2, 3)) diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 97cf725f0..40f5bb591 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -314,7 +314,7 @@ fn command_selection_policy(name: &str) -> &'static str { } } -fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value { +pub(super) fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value { let options = command.options(); let mut accepts = Vec::new(); if command.is_selection_gathering() { diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 2c6358e67..0ee70ef4c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -1273,6 +1273,178 @@ impl<'a> HostSession<'a> { } } + /// Drive a real OCS command one step at a time (see + /// `ocs_plugin_api::host::CommandRequest`). The same primitives the + /// automation channel uses, run synchronously on this thread, so a step + /// never waits on anything that has to arrive from outside. + pub fn run_command( + &mut self, + request: ocs_plugin_api::host::CommandRequest, + ) -> Result { + use crate::command::StepInput; + use ocs_plugin_api::host::CommandRequest as R; + if self.tab != self.app.active_tab { + return Err("commands act on the active document; switch to it first".to_owned()); + } + let tab = self.tab; + let refuse = |line: &str| -> Option { + let first = line.split_whitespace().next()?.trim_start_matches(['\'', '_']).to_uppercase(); + const DENIED: &[&str] = &[ + "QUIT", "EXIT", "CLOSE", "CLOSEALL", "NEW", "QNEW", "OPEN", "SAVE", "QSAVE", "SAVEAS", + "SAVEALL", "RECOVER", "SCRIPT", "RUNSCRIPT", + ]; + if DENIED.contains(&first.as_str()) || first.starts_with("PY_") { + Some(format!("the command {first} cannot be run from a script")) + } else { + None + } + }; + let active = self.app.tabs[tab].active_cmd.is_some(); + let before = self.document().entities().count() as i64; + let error_revision = self.app.command_line.error_revision; + let editor_before = self.app.text_inline.is_some(); + let mtext_before = self.app.mtext_editor.is_some(); + let modal_before = self.app.active_modal.is_some(); + self.app.command_line.unconsumed.clear(); + let task = match request { + R::Run { line } => { + if line.trim().is_empty() { + return Err("a command line is empty".to_owned()); + } + if let Some(reason) = refuse(&line) { + return Err(reason); + } + if active { + return Err("a command is already active; cancel it first".to_owned()); + } + self.app.run_command_line(&line) + } + R::Start { name } => { + if name.trim().is_empty() || name.split_whitespace().count() != 1 { + return Err("a command name is one word".to_owned()); + } + if let Some(reason) = refuse(&name) { + return Err(reason); + } + if active { + return Err("a command is already active; cancel it first".to_owned()); + } + self.app.dispatch_command(name.trim()) + } + R::Cancel => { + let task = self.app.update(crate::app::Message::CommandEscape); + self.app.drive_headless_task(task)?; + // Close what the command opened: an editor or a dialog would leave + // the interface waiting on a script that has moved on. + if !editor_before { + self.app.text_inline = None; + } + if !mtext_before { + self.app.mtext_editor = None; + } + if !modal_before { + self.app.active_modal = None; + } + return Ok(self.command_outcome(before, None)); + } + other => { + if !active { + return Err("no command is waiting for input".to_owned()); + } + let input = match other { + R::Point { point } => { + if point.iter().any(|v| !v.is_finite()) { + return Err("a point must be finite".to_owned()); + } + StepInput::Point(glam::DVec3::new(point[0], point[1], point[2])) + } + R::Text { text } => StepInput::Text(text), + R::Token { text } => { + let task = self.app.feed_active_cmd(&text); + self.app.drive_headless_task(task)?; + return Ok(self.finish_command_step(before, error_revision, editor_before, mtext_before, modal_before)); + } + R::Entity { handle, point } => { + if point.iter().any(|v| !v.is_finite()) { + return Err("a point must be finite".to_owned()); + } + if self.document().get_entity(handle).is_none() { + return Err(format!("entity {} does not exist", handle.value())); + } + StepInput::EntityPick(handle, glam::DVec3::new(point[0], point[1], point[2])) + } + R::Selection => StepInput::SelectionComplete(self.app.tabs[tab].scene.selected_handles_in_order()), + R::Enter => StepInput::Enter, + R::Run { .. } | R::Start { .. } | R::Cancel => unreachable!(), + }; + self.app.feed_command(input) + } + }; + self.app.drive_headless_task(task)?; + Ok(self.finish_command_step(before, error_revision, editor_before, mtext_before, modal_before)) + } + + fn finish_command_step( + &mut self, + before: i64, + error_revision: u64, + editor_before: bool, + mtext_before: bool, + modal_before: bool, + ) -> ocs_plugin_api::host::CommandOutcome { + let error = (self.app.command_line.error_revision != error_revision) + .then(|| self.app.command_line.last_error.clone().unwrap_or_default()); + let blocked = if self.app.tabs[self.tab].active_cmd.is_some() { + Some("command".to_owned()) + } else if let (false, Some(modal)) = (modal_before, self.app.active_modal.as_ref()) { + Some(format!("modal:{modal:?}")) + } else if !editor_before && self.app.text_inline.is_some() { + Some("text_editor".to_owned()) + } else if !mtext_before && self.app.mtext_editor.is_some() { + Some("mtext_editor".to_owned()) + } else { + None + }; + self.set_dirty(); + self.publish_document_view(); + let mut outcome = self.command_outcome(before, blocked); + outcome.error = error; + outcome + } + + fn command_outcome(&self, before: i64, blocked_by: Option) -> ocs_plugin_api::host::CommandOutcome { + let now = self.document().entities().count() as i64; + let metadata = self.app.tabs[self.tab] + .active_cmd + .as_deref() + .map(crate::app::control::active_command_metadata); + let text = |key: &str| metadata.as_ref().and_then(|m| m[key].as_str()).unwrap_or("").to_owned(); + let list = |key: &str| -> Vec { + metadata + .as_ref() + .and_then(|m| m[key].as_array()) + .map(|items| items.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()) + .unwrap_or_default() + }; + let options = metadata + .as_ref() + .and_then(|m| m["options"].as_array()) + .map(|items| items.iter().filter_map(|o| o["keyword"].as_str().filter(|k| !k.is_empty()).map(str::to_owned)).collect()) + .unwrap_or_default(); + ocs_plugin_api::host::CommandOutcome { + status: if blocked_by.is_some() { "waiting_input" } else { "completed" }.to_owned(), + blocked_by, + command: text("name"), + prompt: text("prompt"), + accepts: list("accepts"), + options, + entities: now as u64, + added: now - before, + unconsumed: self.app.command_line.unconsumed.clone(), + error: None, + } + } + /// Simple linetype create/modify/rename/delete. fn linetype_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { use ocs_plugin_api::host::TableOperation; @@ -2367,6 +2539,9 @@ impl HostApi for HostSession<'_> { fn table_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { self.table_operation(operation) } + fn run_command(&mut self, request: ocs_plugin_api::host::CommandRequest) -> Result { + self.run_command(request) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) @@ -7830,6 +8005,207 @@ LT.delete('Temp') assert!(document.entities().any(|e| matches!(e, EntityType::Circle(_))), "undo restored the sheet's circle"); } + /// The modify wrappers through Python over the real runner: each drives + /// the real OCS command and the resulting geometry is checked exactly. + #[test] + fn audit_python_modify_wrappers_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_modify_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "def P(x, y, z=0.0): return {{'x': x, 'y': y, 'z': z}}\n", + "doc = ocs.active_document\nM = doc.modify\nL = doc.layers\n", + "refused, failed = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n failed.append('ACCEPTED_' + tag)\n", + "def step(tag, fn):\n", + " try:\n return fn()\n except Exception as error:\n", + " failed.append(tag + '_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:70])\n", + "{body}\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(failed))\n", + ), body = body)).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, failed) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), failed.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +def line(a, b): return doc.create_entity('Line', start=P(*a), end=P(*b)) +def circle(c, r): return doc.create_entity('Circle', center=P(*c), radius=r) +a = line((0, 0), (10, 0)) +step('offset', lambda: M.offset(a, 2, (5, 5))) +h = line((100, 0), (110, 0)); v = line((105, -5), (105, 5)) +step('trim', lambda: M.trim(h, (108, 0))) +h2 = line((200, 0), (209, 0)); w = line((215, -5), (215, 5)) +step('extend', lambda: M.extend(h2, (208, 0))) +f1 = line((300, 0), (310, 0)); f2 = line((300, 0), (300, 10)) +step('fillet', lambda: M.fillet(f1, f2, 2, (308, 0), (300, 8))) +m = circle((400, 0), 1) +step('move', lambda: M.move([m], (0, 0, 0), (5, 5, 0))) +c = circle((500, 0), 1) +step('copy', lambda: M.copy([c], (0, 0, 0), (0, 10, 0))) +r = line((600, 0), (610, 0)) +step('rotate', lambda: M.rotate([r], (600, 0, 0), 90)) +sc = line((700, 0), (710, 0)) +step('scale', lambda: M.scale([sc], (700, 0, 0), 2)) +mi = line((800, 0), (810, 0)) +step('mirror', lambda: M.mirror([mi], (0, 5, 0), (1, 5, 0))) +e = line((900, 0), (910, 0)) +step('erase', lambda: M.erase([e])) +step('run_line', lambda: doc.command('LINE 1100,0 1110,10')) +def interactive(): + with doc.start_command('CIRCLE') as c: + c.point((1200, 0, 0)) + c.text(3) + if c.waiting: + raise RuntimeError('circle still waiting') +step('interactive', interactive) +check('quit', lambda: doc.start_command('QUIT')) +check('newdoc', lambda: doc.command('NEW')) +check('py', lambda: doc.command('PY_RUN nothing')) +check('unknown', lambda: doc.start_command('NOSUCHCOMMAND')) +check('incomplete', lambda: doc.command('OFFSET')) +check('input_without_command', lambda: ocs.command_step('point', {'point': [0, 0, 0]})) +open_command = doc.start_command('LINE') +check('second_command', lambda: doc.start_command('CIRCLE')) +check('run_while_active', lambda: doc.command('POINT 1,1')) +open_command.cancel() +check('bad_point', lambda: ocs.command_step('point', {'point': [0, 0, 0]})) +"#); + let (refused, failed) = report(host.document()); + assert!(failed.is_empty(), "wrapper failures: {failed:?}"); + assert_eq!(refused, 9); + + let lines: Vec = host.document().entities().filter_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }).collect(); + let circles: Vec = host.document().entities().filter_map(|e| match e { EntityType::Circle(c) => Some(c.clone()), _ => None }).collect(); + let arcs: Vec = host.document().entities().filter_map(|e| match e { EntityType::Arc(a) => Some(a.clone()), _ => None }).collect(); + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + let has_line = |x1: f64, y1: f64, x2: f64, y2: f64| lines.iter().any(|l| { + (near(l.start.x, x1) && near(l.start.y, y1) && near(l.end.x, x2) && near(l.end.y, y2)) + || (near(l.start.x, x2) && near(l.start.y, y2) && near(l.end.x, x1) && near(l.end.y, y1)) + }); + let describe = || lines.iter().map(|l| format!("({},{})-({},{})", l.start.x, l.start.y, l.end.x, l.end.y)).collect::>(); + assert!(has_line(0.0, 0.0, 10.0, 0.0) && has_line(0.0, 2.0, 10.0, 2.0), "offset: {:?}", describe()); + assert!(has_line(100.0, 0.0, 105.0, 0.0), "trim: {:?}", describe()); + assert!(has_line(200.0, 0.0, 215.0, 0.0), "extend: {:?}", describe()); + assert!(arcs.iter().any(|a| near(a.radius, 2.0) && near(a.center.x, 302.0) && near(a.center.y, 2.0)), "fillet arc: {arcs:?}"); + assert!(circles.iter().any(|c| near(c.center.x, 405.0) && near(c.center.y, 5.0)), "move: {circles:?}"); + assert!(!circles.iter().any(|c| near(c.center.x, 400.0)), "move left the original: {circles:?}"); + assert!(circles.iter().any(|c| near(c.center.x, 500.0) && near(c.center.y, 0.0)) && circles.iter().any(|c| near(c.center.x, 500.0) && near(c.center.y, 10.0)), "copy: {circles:?}"); + assert!(has_line(600.0, 0.0, 600.0, 10.0), "rotate: {:?}", describe()); + assert!(has_line(700.0, 0.0, 720.0, 0.0), "scale: {:?}", describe()); + assert!(has_line(800.0, 0.0, 810.0, 0.0) && has_line(800.0, 10.0, 810.0, 10.0), "mirror keeps the source and adds the reflection: {:?}", describe()); + assert!(!has_line(900.0, 0.0, 910.0, 0.0), "erase: {:?}", describe()); + assert!(has_line(1100.0, 0.0, 1110.0, 10.0), "doc.command run: {:?}", describe()); + assert!(circles.iter().any(|c| near(c.center.x, 1200.0) && near(c.radius, 3.0)), "start_command session: {circles:?}"); + assert!(host.app.tabs[0].active_cmd.is_none(), "no command is left running"); + + // U: a wrapper's change is one undoable step (the report layer is the other). + run(&mut host, "undo", r#" +u = doc.create_entity('Circle', center=P(1000, 0), radius=1) +step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) +"#); + assert!(report(host.document()).1.is_empty()); + let moved = |document: &CadDocument, x: f64| document.entities().any(|e| matches!(e, EntityType::Circle(c) if near(c.center.x, x) && near(c.center.y, 0.0))); + assert!(moved(host.document(), 1007.0)); + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(2); + assert!(moved(&app.tabs[0].scene.document, 1000.0), "undo put the circle back"); + assert!(!moved(&app.tabs[0].scene.document, 1007.0)); + } + + /// Exploration harness (not a gate): prints how the real commands answer + /// each step so the wrappers are written against observed prompts. + #[test] + #[ignore] + #[allow(unused_must_use)] + fn spike_command_runner_prompts() { + use ocs_plugin_api::host::CommandRequest as R; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let show = |label: &str, r: &Result| match r { + Ok(o) => eprintln!("SPIKE {label}: {} cmd={} prompt={:?} accepts={:?} opts={:?} added={} unconsumed={:?} err={:?}", + o.status, o.command, o.prompt, o.accepts, o.options, o.added, o.unconsumed, o.error), + Err(e) => eprintln!("SPIKE {label}: ERR {e}"), + }; + let run = |host: &mut HostSession<'_>, label: &str, r: R| { let out = host.run_command(r); show(label, &out); out }; + let mk = |host: &mut HostSession<'_>, lines: &[&str]| -> Vec { + for l in lines { host.run_command(R::Run { line: (*l).into() }).unwrap(); } + host.document().entities().map(|e| e.common().handle).collect() + }; + let cancel = |host: &mut HostSession<'_>| { let _ = host.run_command(R::Cancel); }; + let hs = mk(&mut host, &["LINE 0,0 10,0", "LINE 5,-5 5,5", "LINE 20,0 30,0", "LINE 0,10 10,10", "LINE 0,20 10,20"]); + // EXTEND: extend line 3 toward line 2? use boundary line 2 at x=5; extend line hs[0]'s end at x=10? Extend hs[2] (20..30) leftwards to x=5 no; use hs[0] end to the wall at x=15 + let _ = mk(&mut host, &["LINE 15,-5 15,5"]); + let wall = *host.document().entities().last().map(|e| &e.common().handle).unwrap(); + run(&mut host, "EXTEND start", R::Start { name: "EXTEND".into() }); + run(&mut host, "EXTEND pick", R::Entity { handle: hs[0], point: [9.0, 0.0, 0.0] }); + run(&mut host, "EXTEND enter", R::Enter); + cancel(&mut host); + // FILLET + run(&mut host, "FILLET start", R::Start { name: "FILLET".into() }); + run(&mut host, "FILLET R", R::Token { text: "R".into() }); + run(&mut host, "FILLET radius", R::Text { text: "2".into() }); + run(&mut host, "FILLET first", R::Entity { handle: hs[3], point: [2.0, 10.0, 0.0] }); + run(&mut host, "FILLET second", R::Entity { handle: hs[4], point: [2.0, 20.0, 0.0] }); + cancel(&mut host); + let _ = wall; + // CHAMFER + run(&mut host, "CHAMFER start", R::Start { name: "CHAMFER".into() }); + cancel(&mut host); + // selection commands + host.app.tabs[0].scene.replace_selection_exact(&[hs[3]]); + run(&mut host, "COPY run", R::Run { line: "COPY 0,0 0,3".into() }); + run(&mut host, "COPY leftover", R::Enter); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[3]]); + run(&mut host, "ROTATE run", R::Run { line: "ROTATE 0,0 90".into() }); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + run(&mut host, "SCALE run", R::Run { line: "SCALE 0,0 2".into() }); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + run(&mut host, "MIRROR start", R::Start { name: "MIRROR".into() }); + run(&mut host, "MIRROR sel", R::Selection); + run(&mut host, "MIRROR p1", R::Point { point: [0.0, 0.0, 0.0] }); + run(&mut host, "MIRROR p2", R::Point { point: [1.0, 0.0, 0.0] }); + run(&mut host, "MIRROR erase?", R::Token { text: "N".into() }); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + run(&mut host, "ARRAYRECT start", R::Start { name: "ARRAYRECT".into() }); + run(&mut host, "ARRAYRECT sel", R::Selection); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + run(&mut host, "ARRAYPOLAR start", R::Start { name: "ARRAYPOLAR".into() }); + run(&mut host, "ARRAYPOLAR sel", R::Selection); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[2]]); + run(&mut host, "ERASE run", R::Run { line: "ERASE".into() }); + cancel(&mut host); + host.app.tabs[0].scene.replace_selection_exact(&[hs[0]]); + run(&mut host, "EXPLODE run", R::Run { line: "EXPLODE".into() }); + cancel(&mut host); + eprintln!("SPIKE final entity count {}", host.document().entities().count()); + } + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType; From 360dd853ed68f8eadd6b087a3cc4743f3ebc7096 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 17:34:18 +0200 Subject: [PATCH 56/76] feat(python): chamfer, arrays, explode, join, break, stretch and lengthen wrappers Each is written against the prompt sequence observed in the exploration harness and checked for exact geometry in the real-IPC audit. HATCH is left to create_entity because its command needs an interactive boundary pick. Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 11 ++ plugins/opencad-python/PLUGIN.md | 14 ++- plugins/opencad-python/src/document_model.py | 70 +++++++++++ .../tests/test_document_model.py | 18 ++- src/app/plugin_host.rs | 117 +++++++++++------- 5 files changed, 184 insertions(+), 46 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index e9fb5aac2..e39ffa5f8 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -273,3 +273,14 @@ command, second command, run while active), no command left running, and a one-s Not covered: chamfer, arrays, hatch, join, explode, break and the many other commands (reachable through `doc.command` and `start_command`); reading prompts for commands with a text editor; an on/off setting for script-driven commands. +### 2026-09-21: more modify wrappers + +`doc.modify` gained `chamfer`, `array_rect`, `array_polar`, `explode`, `join`, `break_entity`, `stretch` and `lengthen`, each +written against the prompt sequence observed in `spike_command_runner_prompts` (STRETCH needs an Enter after the crossing +window, LENGTHEN and STRETCH end on Enter, EXPLODE and JOIN use the pre-selection because a pick at their selection prompt +selects nothing). `audit_python_modify_wrappers_over_real_ipc` checks exact geometry for each: the chamfer line, all six +rectangular-array members, the four polar-array members (start and half turn), the exploded segments and the removed polyline, +the joined line without its parts, the two pieces after a break, the stretched and the lengthened end points. HATCH is left to +`create_entity("Hatch")`: its command needs an interactive boundary pick that no scripted step can supply. Not wrapped: PEDIT, +path and 3-D arrays, and the remaining commands, which stay reachable through `doc.command` and `start_command`. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 04e6b1b8c..4af823395 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -281,6 +281,13 @@ M.fillet(first, second, radius=2) # radius 0 squares the corner M.move([a, b], (0, 0, 0), (5, 5, 0)); M.copy([a], base, target) M.rotate([a], base, 90); M.scale([a], base, 2) M.mirror([a], p1, p2, erase_source=False); M.erase([a]) +M.chamfer(first, second, 2, 3) # distance 2 along first, 3 along second +M.array_rect([a], rows=2, columns=3, row_spacing=10, column_spacing=20) +M.array_polar([a], center, count=4, angle=360) # counts include the original +M.explode([polyline]); M.join([line1, line2]) +M.break_entity(line, first_point, second_point) +M.stretch(corner1, corner2, base, target) # crossing window, then the displacement +M.lengthen(line, 5, at=near_the_end) # delta; negative shortens ``` Each step returns an outcome dict: `status` (`completed` or `waiting_input`), @@ -292,7 +299,8 @@ Each step returns an outcome dict: `status` (`completed` or `waiting_input`), also closing any editor or dialog it opened. `doc.command` cancels and raises when the line leaves a prompt open. -Selection-based tools (`move`, `copy`, `rotate`, `scale`, `mirror`, `erase`) select +Selection-based tools (`move`, `copy`, `rotate`, `scale`, `mirror`, `erase`, the arrays, +`explode` and `join`) select the entities you pass first, so the command skips its own selection prompt. Pick points default to the middle of a line, arc or circle; give `at=` for anything else. `trim` and `extend` cut or extend against every entity in the drawing. Each command is @@ -304,7 +312,9 @@ line names a command that could end the session or re-enter Python: `QUIT`, `RECOVER`, `SCRIPT`, `RUNSCRIPT` and any `PY_*`. Commands run on the active document only. Commands that need an interactive surface a script cannot fill (the rich-text editor, a file dialog) report it in `blocked_by` and are cancelled. -`ocs.command_step(kind, options)` is the raw single step behind all of this. +Hatching has no wrapper: create it with `create_entity("Hatch", ...)`, because the +HATCH command needs an interactive boundary pick. `ocs.command_step(kind, options)` +is the raw single step behind all of this. ### Historical command-replay experiment (not in the default build) diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 703352752..72f2456f4 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -751,6 +751,76 @@ class _Modify: lambda c: c.token("Y" if erase_source else "N"), ], entities) + def chamfer(self, first, second, distance1, distance2=None, at1=None, at2=None): + """Cut the corner between two lines; `distance1` is measured along `first` + and `distance2` (default the same) along `second`. Returns the new line.""" + if distance2 is None: + distance2 = distance1 + result = self._run("CHAMFER", [ + lambda c: c.token("D"), + lambda c: c.text(distance1), + lambda c: c.text(distance2), + lambda c: c.entity(first, self._at(first, at1)), + lambda c: c.entity(second, self._at(second, at2)), + ]) + if not result: + raise RuntimeError("CHAMFER produced nothing") + return result[0] + + def array_rect(self, entities, rows, columns, row_spacing, column_spacing): + """Rectangular array of the entities: `rows` by `columns` copies counting + the original, spaced by `row_spacing` and `column_spacing`. Returns the copies.""" + return self._run("ARRAYRECT", [ + lambda c: c.text(rows), + lambda c: c.text(columns), + lambda c: c.text(row_spacing), + lambda c: c.text(column_spacing), + ], entities) + + def array_polar(self, entities, center, count, angle=360): + """Polar array about `center`: `count` items counting the original, spread + over `angle` degrees. Returns the copies.""" + return self._run("ARRAYPOLAR", [ + lambda c: c.point(center), + lambda c: c.text(count), + lambda c: c.text(angle), + ], entities) + + def explode(self, entities): + """Break polylines, blocks and similar entities into their parts; returns the parts.""" + return self._run("EXPLODE", [], entities) + + def join(self, entities): + """Join end-to-end lines, arcs and polylines into one entity; returns it.""" + return self._run("JOIN", [], entities) + + def break_entity(self, entity, first, second): + """Remove the part of `entity` between two points on it; returns the new piece.""" + return self._run("BREAK", [ + lambda c: c.entity(entity, first), + lambda c: c.point(second), + ]) + + def stretch(self, corner1, corner2, base, target): + """Stretch whatever the crossing window (two opposite corners) catches, moving + `base` to `target`; entities fully inside move, ones crossing the edge stretch.""" + self._run("STRETCH", [ + lambda c: c.point(corner1), + lambda c: c.point(corner2), + lambda c: c.enter(), + lambda c: c.point(base), + lambda c: c.point(target), + ]) + + def lengthen(self, entity, delta, at=None): + """Lengthen (or, negative, shorten) `entity` by `delta` at the end nearest `at`.""" + self._run("LENGTHEN", [ + lambda c: c.token("DE"), + lambda c: c.text(delta), + lambda c: c.entity(entity, self._at(entity, at)), + lambda c: c.enter(), + ]) + def erase(self, entities): before = len(list(ocs.entity_handles())) self._select(entities) diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index ada10cfe5..465cbe915 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -58,7 +58,7 @@ class DocumentModelTests(unittest.TestCase): outcome = {"status": "completed", "blocked_by": None, "command": "", "prompt": "", "accepts": [], "options": [], "entities": 0, "added": 0, "unconsumed": [], "error": None} outcome.update(self.step_outcome.get(kind, {})) - if kind == "enter": + if kind in ("enter", "entity"): new = max(self.records) + 1 self.records[new] = {"handle": new, "kind": "Line", "layer": "0", "_editable": True} return outcome @@ -98,6 +98,22 @@ class DocumentModelTests(unittest.TestCase): entity_step = [c for c in self.calls if c[0] == "step" and c[1] == "entity"][0] self.assertEqual(entity_step[2]["point"], [1, 0, 5.0]) + def test_more_modify_wrappers(self): + self.step_outcome = {} + self.doc.modify.chamfer(1, 2, 2, 3, at1=(0, 0, 0), at2=(1, 1, 0)) + texts = [c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"] + self.assertEqual(texts, ["2", "3"]) + self.calls.clear() + self.doc.modify.chamfer(1, 2, 4, at1=(0, 0, 0), at2=(1, 1, 0)) + texts = [c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"] + self.assertEqual(texts, ["4", "4"]) + self.calls.clear() + self.doc.modify.array_rect([1], 2, 3, 10, 20) + self.assertEqual([c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"], ["2", "3", "10", "20"]) + self.calls.clear() + self.doc.modify.stretch((0, 0), (1, 1), (0, 0), (5, 0)) + self.assertEqual([c[1] for c in self.calls if c[0] == "step"], ["start", "point", "point", "enter", "point", "point"]) + def test_batch_edit_and_rollback(self): line = self.doc.entities[1] self.assertEqual(line.start, (1, 2, 3)) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 0ee70ef4c..50be57934 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -8067,6 +8067,22 @@ mi = line((800, 0), (810, 0)) step('mirror', lambda: M.mirror([mi], (0, 5, 0), (1, 5, 0))) e = line((900, 0), (910, 0)) step('erase', lambda: M.erase([e])) +ca = line((1300, 0), (1310, 0)); cb = line((1300, 0), (1300, 10)) +step('chamfer', lambda: M.chamfer(ca, cb, 2, 3, (1308, 0), (1300, 8))) +ar = line((1400, 0), (1401, 0)) +step('array_rect', lambda: M.array_rect([ar], 2, 3, 10, 20)) +ap = line((1500, 0), (1501, 0)) +step('array_polar', lambda: M.array_polar([ap], (1500, 10, 0), 4, 360)) +pl = doc.create_entity('LwPolyline', vertices=[{'location': P(1600, 0)}, {'location': P(1610, 0)}, {'location': P(1610, 10)}]) +step('explode', lambda: M.explode([pl])) +j1 = line((1700, 0), (1705, 0)); j2 = line((1705, 0), (1710, 0)) +step('join', lambda: M.join([j1, j2])) +bk = line((1800, 0), (1810, 0)) +step('break', lambda: M.break_entity(bk, (1802, 0, 0), (1805, 0, 0))) +st = line((1900, 0), (1910, 0)) +step('stretch', lambda: M.stretch((1908, -2, 0), (1912, 2, 0), (1910, 0, 0), (1915, 0, 0))) +ln = line((2000, 0), (2010, 0)) +step('lengthen', lambda: M.lengthen(ln, 5, (2009, 0, 0))) step('run_line', lambda: doc.command('LINE 1100,0 1110,10')) def interactive(): with doc.start_command('CIRCLE') as c: @@ -8112,6 +8128,18 @@ check('bad_point', lambda: ocs.command_step('point', {'point': [0, 0, 0]})) assert!(has_line(800.0, 0.0, 810.0, 0.0) && has_line(800.0, 10.0, 810.0, 10.0), "mirror keeps the source and adds the reflection: {:?}", describe()); assert!(!has_line(900.0, 0.0, 910.0, 0.0), "erase: {:?}", describe()); assert!(has_line(1100.0, 0.0, 1110.0, 10.0), "doc.command run: {:?}", describe()); + assert!(has_line(1302.0, 0.0, 1300.0, 3.0), "chamfer: {:?}", describe()); + for (x, y) in [(1400.0, 0.0), (1420.0, 0.0), (1440.0, 0.0), (1400.0, 10.0), (1420.0, 10.0), (1440.0, 10.0)] { + assert!(has_line(x, y, x + 1.0, y), "rectangular array member at ({x}, {y}): {:?}", describe()); + } + assert!(has_line(1500.0, 0.0, 1501.0, 0.0) && has_line(1500.0, 20.0, 1499.0, 20.0), "polar array (start and half turn): {:?}", describe()); + assert_eq!(lines.iter().filter(|l| l.start.x > 1450.0 && l.start.x < 1520.0 || l.end.x > 1450.0 && l.end.x < 1520.0).count(), 4, "polar array has four members"); + assert!(has_line(1600.0, 0.0, 1610.0, 0.0) && has_line(1610.0, 0.0, 1610.0, 10.0), "explode: {:?}", describe()); + assert!(!host.document().entities().any(|e| matches!(e, EntityType::LwPolyline(p) if p.vertices.first().is_some_and(|v| near(v.location.x, 1600.0)))), "explode removed the polyline"); + assert!(has_line(1700.0, 0.0, 1710.0, 0.0) && !has_line(1700.0, 0.0, 1705.0, 0.0), "join: {:?}", describe()); + assert!(has_line(1800.0, 0.0, 1802.0, 0.0) && has_line(1805.0, 0.0, 1810.0, 0.0) && !has_line(1800.0, 0.0, 1810.0, 0.0), "break: {:?}", describe()); + assert!(has_line(1900.0, 0.0, 1915.0, 0.0), "stretch: {:?}", describe()); + assert!(has_line(2000.0, 0.0, 2015.0, 0.0), "lengthen: {:?}", describe()); assert!(circles.iter().any(|c| near(c.center.x, 1200.0) && near(c.radius, 3.0)), "start_command session: {circles:?}"); assert!(host.app.tabs[0].active_cmd.is_none(), "no command is left running"); @@ -8142,8 +8170,8 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) app.tabs[0].is_start = false; let mut host = HostSession::new(&mut app, 0); let show = |label: &str, r: &Result| match r { - Ok(o) => eprintln!("SPIKE {label}: {} cmd={} prompt={:?} accepts={:?} opts={:?} added={} unconsumed={:?} err={:?}", - o.status, o.command, o.prompt, o.accepts, o.options, o.added, o.unconsumed, o.error), + Ok(o) => eprintln!("SPIKE {label}: {} prompt={:?} accepts={:?} opts={:?} added={} err={:?}", + o.status, o.prompt, o.accepts, o.options, o.added, o.error), Err(e) => eprintln!("SPIKE {label}: ERR {e}"), }; let run = |host: &mut HostSession<'_>, label: &str, r: R| { let out = host.run_command(r); show(label, &out); out }; @@ -8152,60 +8180,63 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) host.document().entities().map(|e| e.common().handle).collect() }; let cancel = |host: &mut HostSession<'_>| { let _ = host.run_command(R::Cancel); }; - let hs = mk(&mut host, &["LINE 0,0 10,0", "LINE 5,-5 5,5", "LINE 20,0 30,0", "LINE 0,10 10,10", "LINE 0,20 10,20"]); - // EXTEND: extend line 3 toward line 2? use boundary line 2 at x=5; extend line hs[0]'s end at x=10? Extend hs[2] (20..30) leftwards to x=5 no; use hs[0] end to the wall at x=15 - let _ = mk(&mut host, &["LINE 15,-5 15,5"]); - let wall = *host.document().entities().last().map(|e| &e.common().handle).unwrap(); - run(&mut host, "EXTEND start", R::Start { name: "EXTEND".into() }); - run(&mut host, "EXTEND pick", R::Entity { handle: hs[0], point: [9.0, 0.0, 0.0] }); - run(&mut host, "EXTEND enter", R::Enter); - cancel(&mut host); - // FILLET - run(&mut host, "FILLET start", R::Start { name: "FILLET".into() }); - run(&mut host, "FILLET R", R::Token { text: "R".into() }); - run(&mut host, "FILLET radius", R::Text { text: "2".into() }); - run(&mut host, "FILLET first", R::Entity { handle: hs[3], point: [2.0, 10.0, 0.0] }); - run(&mut host, "FILLET second", R::Entity { handle: hs[4], point: [2.0, 20.0, 0.0] }); - cancel(&mut host); - let _ = wall; + let hs = mk(&mut host, &["LINE 0,0 10,0", "LINE 0,0 0,10", "PLINE 50,0 60,0 60,10", "LINE 100,0 105,0", "LINE 105,0 110,5", "CIRCLE 200,0 5", "LINE 300,0 310,0"]); // CHAMFER run(&mut host, "CHAMFER start", R::Start { name: "CHAMFER".into() }); + run(&mut host, "CHAMFER D", R::Token { text: "D".into() }); + run(&mut host, "CHAMFER d1", R::Text { text: "2".into() }); + run(&mut host, "CHAMFER d2", R::Text { text: "3".into() }); + run(&mut host, "CHAMFER first", R::Entity { handle: hs[0], point: [8.0, 0.0, 0.0] }); + run(&mut host, "CHAMFER second", R::Entity { handle: hs[1], point: [0.0, 8.0, 0.0] }); cancel(&mut host); - // selection commands - host.app.tabs[0].scene.replace_selection_exact(&[hs[3]]); - run(&mut host, "COPY run", R::Run { line: "COPY 0,0 0,3".into() }); - run(&mut host, "COPY leftover", R::Enter); - cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[3]]); - run(&mut host, "ROTATE run", R::Run { line: "ROTATE 0,0 90".into() }); - cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); - run(&mut host, "SCALE run", R::Run { line: "SCALE 0,0 2".into() }); - cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); - run(&mut host, "MIRROR start", R::Start { name: "MIRROR".into() }); - run(&mut host, "MIRROR sel", R::Selection); - run(&mut host, "MIRROR p1", R::Point { point: [0.0, 0.0, 0.0] }); - run(&mut host, "MIRROR p2", R::Point { point: [1.0, 0.0, 0.0] }); - run(&mut host, "MIRROR erase?", R::Token { text: "N".into() }); - cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + // ARRAYRECT + host.app.tabs[0].scene.replace_selection_exact(&[hs[6]]); run(&mut host, "ARRAYRECT start", R::Start { name: "ARRAYRECT".into() }); - run(&mut host, "ARRAYRECT sel", R::Selection); + run(&mut host, "ARRAYRECT rows", R::Text { text: "2".into() }); + run(&mut host, "ARRAYRECT cols", R::Text { text: "3".into() }); + run(&mut host, "ARRAYRECT rowdist", R::Text { text: "10".into() }); + run(&mut host, "ARRAYRECT coldist", R::Text { text: "20".into() }); + run(&mut host, "ARRAYRECT enter", R::Enter); cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[4]]); + // ARRAYPOLAR + host.app.tabs[0].scene.replace_selection_exact(&[hs[6]]); run(&mut host, "ARRAYPOLAR start", R::Start { name: "ARRAYPOLAR".into() }); - run(&mut host, "ARRAYPOLAR sel", R::Selection); + run(&mut host, "ARRAYPOLAR center", R::Point { point: [300.0, 20.0, 0.0] }); + run(&mut host, "ARRAYPOLAR count", R::Text { text: "4".into() }); + run(&mut host, "ARRAYPOLAR angle", R::Text { text: "360".into() }); + run(&mut host, "ARRAYPOLAR enter", R::Enter); + cancel(&mut host); + // EXPLODE + run(&mut host, "EXPLODE start", R::Start { name: "EXPLODE".into() }); + run(&mut host, "EXPLODE entity", R::Entity { handle: hs[2], point: [55.0, 0.0, 0.0] }); cancel(&mut host); host.app.tabs[0].scene.replace_selection_exact(&[hs[2]]); - run(&mut host, "ERASE run", R::Run { line: "ERASE".into() }); + run(&mut host, "EXPLODE presel", R::Start { name: "EXPLODE".into() }); cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[0]]); - run(&mut host, "EXPLODE run", R::Run { line: "EXPLODE".into() }); + // JOIN + host.app.tabs[0].scene.replace_selection_exact(&[hs[3], hs[4]]); + run(&mut host, "JOIN presel", R::Start { name: "JOIN".into() }); + run(&mut host, "JOIN enter", R::Enter); + cancel(&mut host); + // BREAK + run(&mut host, "BREAK start", R::Start { name: "BREAK".into() }); + run(&mut host, "BREAK entity", R::Entity { handle: hs[6], point: [302.0, 0.0, 0.0] }); + run(&mut host, "BREAK p2", R::Point { point: [305.0, 0.0, 0.0] }); + cancel(&mut host); + // HATCH + run(&mut host, "HATCH start", R::Start { name: "HATCH".into() }); + cancel(&mut host); + // STRETCH, LENGTHEN, JOIN, PEDIT + run(&mut host, "LENGTHEN start", R::Start { name: "LENGTHEN".into() }); + cancel(&mut host); + run(&mut host, "PEDIT start", R::Start { name: "PEDIT".into() }); + cancel(&mut host); + run(&mut host, "STRETCH start", R::Start { name: "STRETCH".into() }); cancel(&mut host); eprintln!("SPIKE final entity count {}", host.document().entities().count()); } + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType; From 81251d9cf365ae3afefe113de5d8119faf02aafd Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 17:42:35 +0200 Subject: [PATCH 57/76] fix(python): validate nested input (vertices, edges) instead of defaulting silently The generated dict-to-struct conversions for nested records defaulted every absent field and ignored unknown keys, so an LwPolyline built from {'x': .., 'y': ..} vertices became zero-coordinate vertices. They now refuse an unknown key (naming the keys the record takes) and a manifest list, required_struct_fields, enforces the fields that define a nested record's geometry (vertex locations, hatch edge ends and centres, section symbol points). Regression test over real IPC; every existing lifecycle audit still passes. Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 13 +++++ plugins/opencad-python/build/generate.rs | 43 +++++++++++++++- plugins/opencad-python/entity_manifest.json | 41 +++++++++++++++ src/app/plugin_host.rs | 55 +++++++++++++++++++++ 4 files changed, 150 insertions(+), 2 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index e39ffa5f8..475c529a3 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -284,3 +284,16 @@ the joined line without its parts, the two pieces after a break, the stretched a `create_entity("Hatch")`: its command needs an interactive boundary pick that no scripted step can supply. Not wrapped: PEDIT, path and 3-D arrays, and the remaining commands, which stay reachable through `doc.command` and `start_command`. +### 2026-09-21: nested input is validated (vertex validation gap) + +Found while writing the `explode` audit: an `LwPolyline` created from vertices that used the wrong key (`{'x': .., 'y': ..}` instead of +`{'location': ..}`) was accepted and became zero-coordinate vertices. The generated dict-to-struct conversions for nested records +defaulted every absent field and ignored unknown keys, so any typo inside a list of vertices, edges or faces vanished silently. +The generator now (1) refuses an unknown key in every nested record, naming the keys it takes, and (2) enforces a manifest list, +`required_struct_fields`, of the fields that define a nested record's geometry: `LwVertex.location`, `Vertex2D.location`, +`Vertex3D.location`, `Vertex3DPolyline.position`, `PolygonMeshVertex.location`, `PolyfaceVertex.location`, `MLineVertex.position`, +`LineEdge.start/end`, `CircularArcEdge.center/radius`, `EllipticArcEdge.center/major_axis_endpoint`, `PolylineEdge.vertices` and +`SectionSymbolPoint.point`. A `None` counts as missing. Everything else in a nested record still defaults when absent. Evidence: +`audit_python_nested_input_is_validated_over_real_ipc` (eight refusals with the right messages for LwPolyline, Polyline2D and +PolygonMesh; nothing invalid reaches the drawing; the valid forms still create), and every existing per-kind lifecycle audit still passes. + diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index b8b511810..88043c48e 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -40,6 +40,11 @@ pub struct Manifest { pub base_fields: Vec, #[serde(default)] pub overrides: std::collections::BTreeMap, + /// Fields of nested structs (a polyline vertex, a hatch edge) that must be + /// present in a scripted dict. Everything else in a nested struct still + /// defaults when absent, but an unknown key is always refused. + #[serde(default)] + pub required_struct_fields: std::collections::BTreeMap>, } fn default_base_fields() -> Vec { @@ -668,12 +673,24 @@ fn default_{snake}() -> {path} {{ ) } -fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String { +fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry, required: &[String]) -> String { let path = type_path(name); let snake = snake_case(name); let mut to_dict_body = String::new(); let mut from_dict_fields = String::new(); let mut default_fields = String::new(); + let mut allowed: Vec = Vec::new(); + for f in &info.fields { + if f.type_id.as_str() != "EntityCommon" { + allowed.push(format!("\"{}\"", f.name)); + } + } + let allowed_list = allowed.join(", "); + let required_list = required + .iter() + .map(|field| format!("\"{field}\"")) + .collect::>() + .join(", "); for f in &info.fields { if f.type_id.as_str() == "EntityCommon" { // A sub-record's own common data (layer, XDATA, handles) is not part @@ -717,6 +734,19 @@ fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> String { fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}> {{ let dict = value.try_into_value::(vm)?; + let allowed: &[&str] = &[{allowed_list}]; + for key in dict.keys_vec() {{ + let key = key.try_into_value::(vm)?; + if !allowed.contains(&key.as_str()) {{ + return Err(vm.new_value_error(format!("{name} has no property {{key:?}}; it takes: {{}}", allowed.join(", ")))); + }} + }} + let required_fields: &[&str] = &[{required_list}]; + for required in required_fields.iter().copied() {{ + if dict.get_item_opt(required, vm)?.map_or(true, |v| vm.is_none(&v)) {{ + return Err(vm.new_value_error(format!("{name} needs {{required}}"))); + }} + }} Ok({path} {{ {from_dict_fields} }}) }} @@ -1050,7 +1080,16 @@ pub fn generate_entity_crud(manifest: &Manifest, registry: &TypeRegistry) -> Str Classify::UnitEnum => out.push_str(&gen_unit_enum(&name, info)), Classify::TaggedEnum => out.push_str(&gen_tagged_enum(&name, info, registry)), Classify::BitFlags => out.push_str(&gen_bitflags(&name, info)), - Classify::Struct => out.push_str(&gen_struct(&name, info, registry)), + Classify::Struct => { + let required = manifest.required_struct_fields.get(&name).cloned().unwrap_or_default(); + for field in &required { + assert!( + info.fields.iter().any(|f| &f.name == field), + "entity_manifest.json: required_struct_fields: {name} has no field {field}" + ); + } + out.push_str(&gen_struct(&name, info, registry, &required)) + } } } diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 298087fed..a714143e4 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -973,5 +973,46 @@ } } } + }, + "required_struct_fields": { + "LwVertex": [ + "location" + ], + "Vertex2D": [ + "location" + ], + "Vertex3D": [ + "location" + ], + "Vertex3DPolyline": [ + "position" + ], + "PolygonMeshVertex": [ + "location" + ], + "PolyfaceVertex": [ + "location" + ], + "MLineVertex": [ + "position" + ], + "LineEdge": [ + "start", + "end" + ], + "CircularArcEdge": [ + "center", + "radius" + ], + "EllipticArcEdge": [ + "center", + "major_axis_endpoint" + ], + "PolylineEdge": [ + "vertices" + ], + "SectionSymbolPoint": [ + "point" + ] } } diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 50be57934..95de91941 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -8159,6 +8159,61 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) assert!(!moved(&app.tabs[0].scene.document, 1007.0)); } + /// A scripted dict is checked all the way down: a nested record (a polyline + /// vertex, a mesh vertex) may not carry an unknown key or omit the field that + /// defines its geometry, so a typo never turns into a silent zero coordinate. + #[test] + fn audit_python_nested_input_is_validated_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let script = std::env::temp_dir().join(format!("ocs_nested_{}.py", std::process::id())); + std::fs::write(&script, concat!( + "def P(x, y, z=0.0): return {'x': x, 'y': y, 'z': z}\n", + "doc = ocs.active_document\nL = doc.layers\n", + "refused, problems = [], []\n", + "def check(tag, fn, expect):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError) as error:\n", + " if expect in str(error):\n refused.append(tag)\n", + " else:\n problems.append('WRONGMESSAGE_' + tag)\n", + " else:\n problems.append('ACCEPTED_' + tag)\n", + "def lw(vertices): return lambda: doc.create_entity('LwPolyline', vertices=vertices)\n", + "ok = {'location': P(0, 0)}\n", + "check('lw_xy_keys', lw([ok, {'x': 5, 'y': 5}]), 'no property')\n", + "check('lw_empty_vertex', lw([ok, {}]), 'needs location')\n", + "check('lw_null_location', lw([ok, {'location': None}]), 'needs location')\n", + "check('lw_typo', lw([ok, {'location': P(1, 1), 'bulgee': 0.5}]), 'no property')\n", + "check('lw_message_lists_keys', lw([ok, {'lokation': P(1, 1)}]), 'location')\n", + "check('poly2d_missing_location', lambda: doc.create_entity('Polyline2D', vertices=[{'flags': 0}, {'location': P(1, 1)}]), 'needs location')\n", + "check('poly2d_typo', lambda: doc.create_entity('Polyline2D', vertices=[{'location': P(0, 0)}, {'loc': P(1, 1)}]), 'no property')\n", + "check('mesh_missing_location', lambda: doc.create_entity('PolygonMesh', vertices=[{}]), 'needs location')\n", + "good = doc.create_entity('LwPolyline', vertices=[ok, {'location': P(10, 0), 'bulge': 0.5}, {'location': P(10, 10)}])\n", + "good2 = doc.create_entity('Polyline2D', vertices=[{'location': P(0, 0)}, {'location': P(5, 5)}])\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(problems))\n", + )).unwrap(); + assert!(process.dispatch(&mut host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + let name = host.document().layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last().expect("script report layer"); + let (refused, problems) = name["REPORT".len()..].split_once('~').unwrap(); + assert_eq!(problems.trim(), "", "problems: {problems}"); + assert_eq!(refused.trim(), "8"); + // Nothing invalid reached the drawing; the two valid polylines did. + let document = host.document(); + let lw: Vec<_> = document.entities().filter_map(|e| match e { EntityType::LwPolyline(p) => Some(p.clone()), _ => None }).collect(); + assert_eq!(lw.len(), 1, "only the valid LwPolyline was created"); + assert_eq!(lw[0].vertices.len(), 3); + assert_eq!(lw[0].vertices[1].bulge, 0.5); + assert_eq!(document.entities().filter(|e| matches!(e, EntityType::Polyline2D(_))).count(), 1); + assert!(!document.entities().any(|e| matches!(e, EntityType::PolygonMesh(_)))); + drop(process); + } + /// Exploration harness (not a gate): prints how the real commands answer /// each step so the wrappers are written against observed prompts. #[test] From b180dce1a18fe49c67dcc61c8a89f79fea6fbb6c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 17:57:58 +0200 Subject: [PATCH 58/76] feat(python): PEDIT polyline wrappers and path/3-D arrays polyline_close/open/width/reverse/join (a line or arc is turned into a polyline first), array_path and array_3d, written against observed prompts and checked for exact geometry in the real-IPC audit. Polylines default their pick point to the first vertex. The audit runs on a large-stack thread because PEDIT's line-to-polyline step needs more than a test thread's 2 MiB in a debug build. Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 12 ++ plugins/opencad-python/PLUGIN.md | 11 +- plugins/opencad-python/src/document_model.py | 75 +++++++++++ .../tests/test_document_model.py | 16 +++ src/app/plugin_host.rs | 118 ++++++++++-------- 5 files changed, 175 insertions(+), 57 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 475c529a3..4f7b05400 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -297,3 +297,15 @@ The generator now (1) refuses an unknown key in every nested record, naming the `audit_python_nested_input_is_validated_over_real_ipc` (eight refusals with the right messages for LwPolyline, Polyline2D and PolygonMesh; nothing invalid reaches the drawing; the valid forms still create), and every existing per-kind lifecycle audit still passes. +### 2026-09-21: PEDIT and the remaining arrays + +`doc.modify` gained `array_path`, `array_3d`, `polyline_close`, `polyline_open`, `polyline_width`, `polyline_reverse` and `polyline_join`, +written against the prompts observed in `spike_command_runner_prompts` (PEDIT offers to turn a line or arc into a polyline and the wrapper +accepts; the join option takes a selection, and its Enter completes the whole command). The default pick point for a polyline is its first +vertex. `audit_python_modify_wrappers_over_real_ipc` checks: closed and opened flags, the widened polyline, the reversed vertex order, the +joined polyline's three vertices with its line consumed, a line turned into a widened polyline (and the line gone), all five path-array +positions (2600 to 2650 in 12.5 steps), and all eight members of the 3-D array (x 2700/2720, y 0/10, z 0/30). Finding: PEDIT's line-to-polyline +step needs between 2 and 4 MiB of stack in a debug build, more than a test thread's 2 MiB, so that audit runs on a 64 MiB thread; the +application's main thread has 8 MiB, and a release build uses far less. Not wrapped: PEDIT fit, spline, decurve, linetype generation and +vertex editing. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 4af823395..c02694cac 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -288,6 +288,11 @@ M.explode([polyline]); M.join([line1, line2]) M.break_entity(line, first_point, second_point) M.stretch(corner1, corner2, base, target) # crossing window, then the displacement M.lengthen(line, 5, at=near_the_end) # delta; negative shortens +M.array_path([a], path_curve, count=5) # evenly along a curve +M.array_3d([a], rows=2, columns=2, levels=2, row_spacing=10, column_spacing=20, level_spacing=30) +M.polyline_close(pl); M.polyline_open(pl); M.polyline_reverse(pl) +M.polyline_width(line_or_pl, 0.5) # a line or arc is turned into a polyline first +M.polyline_join(pl, [line1, arc1]) # returns the merged polyline ``` Each step returns an outcome dict: `status` (`completed` or `waiting_input`), @@ -302,7 +307,7 @@ the line leaves a prompt open. Selection-based tools (`move`, `copy`, `rotate`, `scale`, `mirror`, `erase`, the arrays, `explode` and `join`) select the entities you pass first, so the command skips its own selection prompt. Pick -points default to the middle of a line, arc or circle; give `at=` for anything else. +points default to the middle of a line, arc or circle and the first vertex of a polyline; give `at=` for anything else. `trim` and `extend` cut or extend against every entity in the drawing. Each command is undone as the editor would undo it. @@ -312,7 +317,9 @@ line names a command that could end the session or re-enter Python: `QUIT`, `RECOVER`, `SCRIPT`, `RUNSCRIPT` and any `PY_*`. Commands run on the active document only. Commands that need an interactive surface a script cannot fill (the rich-text editor, a file dialog) report it in `blocked_by` and are cancelled. -Hatching has no wrapper: create it with `create_entity("Hatch", ...)`, because the +PEDIT's fit, spline, decurve and vertex-edit options are not wrapped (reach them with +`start_command("PEDIT")`); `polyline_*` return the polyline entity, which is a new +entity when the command replaced the original. Hatching has no wrapper: create it with `create_entity("Hatch", ...)`, because the HATCH command needs an interactive boundary pick. `ocs.command_step(kind, options)` is the raw single step behind all of this. diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 72f2456f4..81d77d5ed 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -678,6 +678,14 @@ class _Modify: return _pt(at) samples = ocs.curve_samples(_handle(entity), 4) if samples is None: + # A polyline: its first vertex lies on it. + data = ocs.entity_descriptor(_handle(entity)) or {} + vertices = data.get("vertices") + if vertices: + first = vertices[0] + where = first.get("location") or first.get("position") if isinstance(first, dict) else None + if where: + return [float(where["x"]), float(where["y"]), float(where.get("z", 0.0))] raise ValueError("pass at=(x, y, z): the middle of this kind of entity is not known") middle = samples["vertices"][len(samples["vertices"]) // 2] return [middle[0], middle[1], samples["elevation"]] @@ -821,6 +829,73 @@ class _Modify: lambda c: c.enter(), ]) + def array_path(self, entities, path, count, at=None): + """Array the entities along the curve `path`: `count` items counting the + original. Returns the copies.""" + return self._run("ARRAYPATH", [ + lambda c: c.entity(path, self._at(path, at)), + lambda c: c.text(count), + ], entities) + + def array_3d(self, entities, rows, columns, levels, row_spacing, column_spacing, level_spacing): + """Three-dimensional rectangular array (rows along Y, columns along X, levels + along Z); counts include the original. Returns the copies.""" + return self._run("ARRAY3D", [ + lambda c: c.text(rows), + lambda c: c.text(columns), + lambda c: c.text(levels), + lambda c: c.text(row_spacing), + lambda c: c.text(column_spacing), + lambda c: c.text(level_spacing), + ], entities) + + def _pedit(self, entity, steps, at=None): + """Run PEDIT on one entity: pick it, accept the offer to turn a line or arc + into a polyline, apply `steps`, then exit. Returns the polyline entity.""" + handle = _handle(entity) + before = list(ocs.entity_handles()) + with _Command("PEDIT") as command: + command.entity(handle, self._at(entity, at)) + if "Turn it into one" in command.outcome["prompt"]: + command.token("Y") + for step in steps: + step(command) + if command.waiting: + command.token("X") + _finished(command, "PEDIT") + if handle in set(ocs.entity_handles()): + return self._document.entities[handle] + added = self._added(before) + if not added: + raise RuntimeError("PEDIT left no polyline") + return added[0] + + def polyline_close(self, entity, at=None): + """Close an open polyline (a line or arc is first turned into one).""" + return self._pedit(entity, [lambda c: c.token("C")], at) + + def polyline_open(self, entity, at=None): + """Open a closed polyline.""" + return self._pedit(entity, [lambda c: c.token("O")], at) + + def polyline_width(self, entity, width, at=None): + """Give every segment of a polyline the same width.""" + return self._pedit(entity, [lambda c: c.token("W"), lambda c: c.text(width)], at) + + def polyline_reverse(self, entity, at=None): + """Reverse a polyline's direction.""" + return self._pedit(entity, [lambda c: c.token("R")], at) + + def polyline_join(self, entity, others, at=None): + """Join lines, arcs and polylines that meet the polyline's ends into it.""" + def join(c): + c.token("J") + self._select(others) + c.selection() + if c.waiting and "Join" in c.outcome["prompt"]: + c.enter() + return self._pedit(entity, [join], at) + def erase(self, entities): before = len(list(ocs.entity_handles())) self._select(entities) diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index 465cbe915..881014d83 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -58,6 +58,8 @@ class DocumentModelTests(unittest.TestCase): outcome = {"status": "completed", "blocked_by": None, "command": "", "prompt": "", "accepts": [], "options": [], "entities": 0, "added": 0, "unconsumed": [], "error": None} outcome.update(self.step_outcome.get(kind, {})) + if kind == "token" and options.get("text") == "X": + outcome["status"] = "completed" if kind in ("enter", "entity"): new = max(self.records) + 1 self.records[new] = {"handle": new, "kind": "Line", "layer": "0", "_editable": True} @@ -114,6 +116,20 @@ class DocumentModelTests(unittest.TestCase): self.doc.modify.stretch((0, 0), (1, 1), (0, 0), (5, 0)) self.assertEqual([c[1] for c in self.calls if c[0] == "step"], ["start", "point", "point", "enter", "point", "point"]) + def test_pedit_and_array_wrappers(self): + self.step_outcome = {"start": {"status": "waiting_input"}, "entity": {"status": "waiting_input", "prompt": "Turn it into one? [Yes/No]"}, + "token": {"status": "waiting_input"}} + self.doc.modify.polyline_close(1) + tokens = [c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "token"] + self.assertEqual(tokens, ["Y", "C", "X"]) + self.calls.clear() + self.step_outcome = {} + self.doc.modify.array_3d([1], 2, 2, 2, 10, 20, 30) + self.assertEqual([c[2]["text"] for c in self.calls if c[0] == "step" and c[1] == "text"], ["2", "2", "2", "10", "20", "30"]) + self.calls.clear() + self.doc.modify.array_path([1], 2, 5, at=(0, 0, 0)) + self.assertEqual([c[1] for c in self.calls if c[0] == "step"], ["start", "entity", "text"]) + def test_batch_edit_and_rollback(self): line = self.doc.entities[1] self.assertEqual(line.start, (1, 2, 3)) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 95de91941..a94c9f01c 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -8009,6 +8009,17 @@ LT.delete('Temp') /// the real OCS command and the resulting geometry is checked exactly. #[test] fn audit_python_modify_wrappers_over_real_ipc() { + // PEDIT's line-to-polyline conversion runs deep inside a nested command step; a debug + // build needs more than the 2 MiB a test thread gets (the application's main thread has 8 MiB). + std::thread::Builder::new() + .stack_size(64 * 1024 * 1024) + .spawn(modify_wrappers_audit_body) + .unwrap() + .join() + .unwrap(); + } + + fn modify_wrappers_audit_body() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { return; }; @@ -8030,9 +8041,9 @@ LT.delete('Temp') " else:\n failed.append('ACCEPTED_' + tag)\n", "def step(tag, fn):\n", " try:\n return fn()\n except Exception as error:\n", - " failed.append(tag + '_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:70])\n", + " failed.append(tag + '_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:45])\n", "{body}\n", - "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(failed))\n", + "L.create(('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(failed))[:250])\n", ), body = body)).unwrap(); assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); let _ = std::fs::remove_file(&script); @@ -8083,6 +8094,23 @@ st = line((1900, 0), (1910, 0)) step('stretch', lambda: M.stretch((1908, -2, 0), (1912, 2, 0), (1910, 0, 0), (1915, 0, 0))) ln = line((2000, 0), (2010, 0)) step('lengthen', lambda: M.lengthen(ln, 5, (2009, 0, 0))) +def lwp(pts, closed=False): return doc.create_entity('LwPolyline', is_closed=closed, vertices=[{'location': P(*p)} for p in pts]) +pc = lwp([(2100, 0), (2110, 0), (2110, 10)]) +step('polyline_close', lambda: M.polyline_close(pc)) +po = lwp([(2150, 0), (2160, 0), (2160, 10)], True) +step('polyline_open', lambda: M.polyline_open(po)) +pw = lwp([(2200, 0), (2210, 0), (2210, 10)]) +step('polyline_width', lambda: M.polyline_width(pw, 0.5)) +pr = lwp([(2300, 0), (2310, 0), (2310, 10)]) +step('polyline_reverse', lambda: M.polyline_reverse(pr)) +pj = lwp([(2400, 0), (2410, 0)]); pjl = line((2410, 0), (2420, 0)) +step('polyline_join', lambda: M.polyline_join(pj, [pjl])) +pline = line((2500, 0), (2510, 0)) +step('polyline_from_line', lambda: M.polyline_width(pline, 1)) +pb = line((2600, 0), (2601, 0)); pp = line((2600, 50), (2650, 50)) +step('array_path', lambda: M.array_path([pb], pp, 5)) +a3 = line((2700, 0), (2701, 0)) +step('array_3d', lambda: M.array_3d([a3], 2, 2, 2, 10, 20, 30)) step('run_line', lambda: doc.command('LINE 1100,0 1110,10')) def interactive(): with doc.start_command('CIRCLE') as c: @@ -8140,6 +8168,29 @@ check('bad_point', lambda: ocs.command_step('point', {'point': [0, 0, 0]})) assert!(has_line(1800.0, 0.0, 1802.0, 0.0) && has_line(1805.0, 0.0, 1810.0, 0.0) && !has_line(1800.0, 0.0, 1810.0, 0.0), "break: {:?}", describe()); assert!(has_line(1900.0, 0.0, 1915.0, 0.0), "stretch: {:?}", describe()); assert!(has_line(2000.0, 0.0, 2015.0, 0.0), "lengthen: {:?}", describe()); + let polylines: Vec = host.document().entities().filter_map(|e| match e { EntityType::LwPolyline(p) => Some(p.clone()), _ => None }).collect(); + let at = |x: f64| polylines.iter().find(|p| p.vertices.iter().any(|v| near(v.location.x, x))); + let pc = at(2100.0).expect("closed polyline"); + assert!(pc.is_closed, "polyline_close"); + assert!(!at(2150.0).expect("opened polyline").is_closed, "polyline_open"); + let pw = at(2200.0).expect("widened polyline"); + assert!(pw.constant_width == 0.5 || pw.vertices.iter().all(|v| v.start_width == 0.5 && v.end_width == 0.5), "polyline_width: {pw:?}"); + let pr = at(2300.0).expect("reversed polyline"); + assert_eq!((pr.vertices[0].location.x, pr.vertices[0].location.y), (2310.0, 10.0), "polyline_reverse: {pr:?}"); + let pj = polylines.iter().find(|p| p.vertices.iter().any(|v| near(v.location.x, 2420.0))).expect("joined polyline"); + assert_eq!(pj.vertices.len(), 3, "polyline_join: {pj:?}"); + assert!(!has_line(2410.0, 0.0, 2420.0, 0.0), "polyline_join consumed the line: {:?}", describe()); + let from_line = at(2500.0).expect("a line turned into a polyline"); + assert!(from_line.constant_width == 1.0 || from_line.vertices.iter().all(|v| v.start_width == 1.0), "polyline_from_line: {from_line:?}"); + assert!(!has_line(2500.0, 0.0, 2510.0, 0.0), "the line became the polyline: {:?}", describe()); + let path_copies: Vec<(f64, f64)> = lines.iter().filter(|l| l.start.x > 2599.0 && l.start.x < 2660.0).map(|l| (l.start.x, l.start.y)).collect(); + for x in [2600.0, 2612.5, 2625.0, 2637.5, 2650.0] { + assert!(path_copies.iter().any(|p| near(p.0, x) && near(p.1, 0.0)), "array_path item at x={x} (five evenly spaced along the 50-long path): {path_copies:?}"); + } + let three_d: Vec<(f64, f64, f64)> = lines.iter().filter(|l| l.start.x > 2699.0 && l.start.x < 2725.0).map(|l| (l.start.x, l.start.y, l.start.z)).collect(); + for x in [2700.0, 2720.0] { for y in [0.0, 10.0] { for z in [0.0, 30.0] { + assert!(three_d.iter().any(|p| near(p.0, x) && near(p.1, y) && near(p.2, z)), "array_3d member ({x},{y},{z}): {three_d:?}"); + } } } assert!(circles.iter().any(|c| near(c.center.x, 1200.0) && near(c.radius, 3.0)), "start_command session: {circles:?}"); assert!(host.app.tabs[0].active_cmd.is_none(), "no command is left running"); @@ -8234,64 +8285,21 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) for l in lines { host.run_command(R::Run { line: (*l).into() }).unwrap(); } host.document().entities().map(|e| e.common().handle).collect() }; - let cancel = |host: &mut HostSession<'_>| { let _ = host.run_command(R::Cancel); }; - let hs = mk(&mut host, &["LINE 0,0 10,0", "LINE 0,0 0,10", "PLINE 50,0 60,0 60,10", "LINE 100,0 105,0", "LINE 105,0 110,5", "CIRCLE 200,0 5", "LINE 300,0 310,0"]); - // CHAMFER - run(&mut host, "CHAMFER start", R::Start { name: "CHAMFER".into() }); - run(&mut host, "CHAMFER D", R::Token { text: "D".into() }); - run(&mut host, "CHAMFER d1", R::Text { text: "2".into() }); - run(&mut host, "CHAMFER d2", R::Text { text: "3".into() }); - run(&mut host, "CHAMFER first", R::Entity { handle: hs[0], point: [8.0, 0.0, 0.0] }); - run(&mut host, "CHAMFER second", R::Entity { handle: hs[1], point: [0.0, 8.0, 0.0] }); - cancel(&mut host); - // ARRAYRECT - host.app.tabs[0].scene.replace_selection_exact(&[hs[6]]); - run(&mut host, "ARRAYRECT start", R::Start { name: "ARRAYRECT".into() }); - run(&mut host, "ARRAYRECT rows", R::Text { text: "2".into() }); - run(&mut host, "ARRAYRECT cols", R::Text { text: "3".into() }); - run(&mut host, "ARRAYRECT rowdist", R::Text { text: "10".into() }); - run(&mut host, "ARRAYRECT coldist", R::Text { text: "20".into() }); - run(&mut host, "ARRAYRECT enter", R::Enter); - cancel(&mut host); - // ARRAYPOLAR - host.app.tabs[0].scene.replace_selection_exact(&[hs[6]]); - run(&mut host, "ARRAYPOLAR start", R::Start { name: "ARRAYPOLAR".into() }); - run(&mut host, "ARRAYPOLAR center", R::Point { point: [300.0, 20.0, 0.0] }); - run(&mut host, "ARRAYPOLAR count", R::Text { text: "4".into() }); - run(&mut host, "ARRAYPOLAR angle", R::Text { text: "360".into() }); - run(&mut host, "ARRAYPOLAR enter", R::Enter); - cancel(&mut host); - // EXPLODE - run(&mut host, "EXPLODE start", R::Start { name: "EXPLODE".into() }); - run(&mut host, "EXPLODE entity", R::Entity { handle: hs[2], point: [55.0, 0.0, 0.0] }); - cancel(&mut host); - host.app.tabs[0].scene.replace_selection_exact(&[hs[2]]); - run(&mut host, "EXPLODE presel", R::Start { name: "EXPLODE".into() }); - cancel(&mut host); - // JOIN - host.app.tabs[0].scene.replace_selection_exact(&[hs[3], hs[4]]); - run(&mut host, "JOIN presel", R::Start { name: "JOIN".into() }); - run(&mut host, "JOIN enter", R::Enter); - cancel(&mut host); - // BREAK - run(&mut host, "BREAK start", R::Start { name: "BREAK".into() }); - run(&mut host, "BREAK entity", R::Entity { handle: hs[6], point: [302.0, 0.0, 0.0] }); - run(&mut host, "BREAK p2", R::Point { point: [305.0, 0.0, 0.0] }); - cancel(&mut host); - // HATCH - run(&mut host, "HATCH start", R::Start { name: "HATCH".into() }); - cancel(&mut host); - // STRETCH, LENGTHEN, JOIN, PEDIT - run(&mut host, "LENGTHEN start", R::Start { name: "LENGTHEN".into() }); - cancel(&mut host); + let hs = mk(&mut host, &["PLINE 2400,0 2410,0", "LINE 2410,0 2420,0"]); run(&mut host, "PEDIT start", R::Start { name: "PEDIT".into() }); - cancel(&mut host); - run(&mut host, "STRETCH start", R::Start { name: "STRETCH".into() }); - cancel(&mut host); + run(&mut host, "pick", R::Entity { handle: hs[0], point: [2400.0, 0.0, 0.0] }); + run(&mut host, "J", R::Token { text: "J".into() }); + host.app.tabs[0].scene.replace_selection_exact(&[hs[1]]); + run(&mut host, "selection", R::Selection); + run(&mut host, "enter", R::Enter); + run(&mut host, "X", R::Token { text: "X".into() }); eprintln!("SPIKE final entity count {}", host.document().entities().count()); } + + + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType; From 8eb85dab8d7cc1390b66d03110384aa46227ac3f Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 18:27:59 +0200 Subject: [PATCH 59/76] docs: final review pass over the Python host documentation Rewrite the host-model design note for the current architecture (entities, tables, commands, nested validation, known limits), refresh the plan's current-state header, the ledger and gaps-doc status lines (cadcodec PR #48), the README status and the superseded ocs.command notes, and drop dangling references to a sibling project that is not in this repository. Co-Authored-By: Claude Sonnet 5 --- docs/cadcodec-reader-gaps.md | 7 +- docs/plugin-host-model-completion-plan.md | 25 +-- docs/plugin-host-model-coverage-ledger.md | 4 +- docs/plugin-host-model.md | 216 +++++++++++++--------- plugins/opencad-python/PLUGIN.md | 50 ++--- plugins/opencad-python/README.md | 23 ++- plugins/opencad-python/ROADMAP.md | 18 +- 7 files changed, 179 insertions(+), 164 deletions(-) diff --git a/docs/cadcodec-reader-gaps.md b/docs/cadcodec-reader-gaps.md index a23110ff2..f446c121e 100644 --- a/docs/cadcodec-reader-gaps.md +++ b/docs/cadcodec-reader-gaps.md @@ -1,7 +1,10 @@ # cadcodec DXF reader/writer gaps found by the OCS Python host audit -Draft issue reports for `HakanSeven12/cadcodec` (the `acadrust` crate). Nothing -here has been filed. Every finding was made against **cadcodec revision +Reports on `HakanSeven12/cadcodec` (the `acadrust` crate). **Status (21 September +2026):** fixes for issues 1-6 below, with regression tests, are submitted as +[HakanSeven12/cadcodec#48](https://github.com/HakanSeven12/cadcodec/pull/48) (branch +`fix/dxf-reader-writer-roundtrip` on `felixriestra/cadcodec`); the five findings under +"Observed but not filed" and the style and block ones added later are not filed. Every finding was made against **cadcodec revision `5b682ed`** (OCS pins `acadrust` to it) by saving a document with `DxfWriter` or `DwgWriter`, reloading it, and comparing entity fields. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 4f7b05400..fc5037976 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -2,20 +2,14 @@ ## Goal and current state -Build a general Python document model over the OCS host API for all **43 canvas kinds**. A kind is **complete** only after the per-kind gate below passes. Merely appearing in a converter or allowing a `layer` edit is partial coverage. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). Do not resume the separate Lisp-to-Python conversion work. +Build a general Python document model over the OCS host API so a script can control OCS: read and change every canvas entity, manage the drawing's tables, and run OCS's own commands. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). The architecture and guarantees are in [plugin-host-model.md](plugin-host-model.md); the progress log at the end of this file records each increment with its evidence. Do not resume the separate Lisp-to-Python conversion work. -As of 20 September 2026, the working branch is `plugin/host-model-api` at -`a8dd3640` plus the Leader increment, published as draft PR -[#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **43 of 43** canvas kinds: 41 -are creatable (the legacy `Polyline` and `Body` are update-only). This is **not** 41 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D, Region, Surface, Ole2Frame and Viewport pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. -AttributeDefinition passes its host and real IPC lifecycle but remains short of -`Complete` because the previously pinned CAD codec dropped optional ATTDEF -fields during DXF reads; revalidate that blocker against the current -`acadrust` revision `7ea4247`. Leader is mapped and integration-tested but blocked at W by three acadrust persistence gaps (see the ledger). Leader, Helix, Underlay, ViewBorder, Light and SectionSymbol are mapped and integration-tested but blocked at W by acadrust persistence gaps, and RasterImage is held short of `Complete` by unverifiable image-dictionary linkage (see the ledger). RasterImage is mapped and integration-tested, held short of `Complete` by unverifiable image-dictionary linkage. Every canvas kind now has explicit writes; SectionSymbol is mapped and integration-tested but blocked at DXF persistence by cadcodec -writes through the document model. All 43 have raw typed snapshots. There are -three internal records and two opaque fallbacks outside the 43 canvas kinds. +**State as of 21 September 2026.** The working branch is `plugin/host-model-api` (host API v7), backed up on the `felixriestra/OpenCADStudio` fork. The first draft PR, [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391), was closed on 21 September with a note that a more complete version will follow; no new PR will be opened until the work is finished and tested as far as it can be. + +- **Entities:** explicit writes for all 43 canvas kinds (41 creatable; the legacy `Polyline` and `Body` are update-only). **31 kinds are `Complete`** (every C/R/E/D/U/I/W/V/P gate passes). The remaining kinds are integration-tested and held short of `Complete` by a named blocker in the ledger: seven by cadcodec DXF bugs (fixes submitted as [cadcodec#48](https://github.com/HakanSeven12/cadcodec/pull/48), not yet merged, so OCS still pins the unfixed revision), Polyline3D by the DWG format, Polyline2D by a kind change on DXF save, RasterImage by an image-reactor structure that cannot be verified, and Body by having no creation path. +- **Drawing tables:** layers, text and dimension styles, blocks and their contents, linetypes and layouts, through the additive `TableOperation` request (see the log entries dated 2026-09-21). +- **Commands:** `HostApi::run_command` drives the real OCS commands step by step; `doc.command`, `doc.start_command` and `doc.modify` build on it. +- **Known open items:** the unfiled cadcodec findings (`cadcodec-reader-gaps.md`), complex (text and shape) linetypes, plot devices and named page setups, PEDIT's remaining options, and an on/off setting for script-driven commands. Repositories: @@ -31,9 +25,8 @@ Repositories: Preserve unrelated untracked files. Host and adapter changes now live in the same OCS repository and commit. Push the feature branch only to the -`felixriestra/OpenCADStudio` fork; PR #1391 updates automatically. Never push a -feature commit directly to H7's `main`. Verify both upstream `main` and the fork -branch before each push. +`felixriestra/OpenCADStudio` fork, and only when asked. Never push to H7's +repository. Verify both upstream `main` and the fork branch before each push. ## First: complete the originally agreed Phase 8 diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index 6d9c346a7..e2f8fe0f2 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,8 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **43 of 43 canvas kinds have explicit geometry writes** (41 creatable, plus update-only legacy `Polyline` and `Body`), **31 kinds are complete**, and none remains layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 21 September 2026 (the object tables, block contents and command runner added since are described in [the design note](plugin-host-model.md) and the plan's progress log; they are not entity kinds): **43 of 43 canvas kinds have explicit geometry writes** (41 creatable, plus update-only legacy `Polyline` and `Body`), **31 kinds are complete**, and none remains layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. + +**Rows blocked at W by cadcodec.** Fixes for six of the DXF bugs behind them are submitted as [HakanSeven12/cadcodec#48](https://github.com/HakanSeven12/cadcodec/pull/48) (ATTDEF fields, boolean group codes for Helix and Light, UNDERLAY rotation, LEADER link and vectors, DRAWINGVIEW/SECTIONLINE dispatch, TABLE merged ranges). OCS still pins the unfixed revision `5b682ed`, so those rows stay blocked and their canaries still assert the wrong result; the expectation is that they flip and the rows move to `Complete` once #48 is merged and OCS repins. That has been checked in cadcodec's own round-trip tests, not yet in OCS. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 39a91be8c..76a7758f6 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -1,99 +1,133 @@ # Scripting host model (API v7) -The host owns the drawing. Plugins can read the full `CadDocument` snapshot and -commit cloned `EntityType` values through IPC. `update_entities_transaction` -validates every replacement before changing the drawing, records one undo -entry, refreshes scene caches and the shared document view, and marks the -document dirty. The request preserves handles, owners, and entity kinds. A -missing or duplicate handle, a kind/owner change, or a locked source layer -rejects the whole batch. An empty batch does nothing. +This note describes how a script controls OCS and what the host guarantees. The +per-kind evidence is in the [coverage ledger](plugin-host-model-coverage-ledger.md), +the increment-by-increment record in the [completion plan](plugin-host-model-completion-plan.md), +and the script-facing reference in `plugins/opencad-python/PLUGIN.md`. -This is a full-entity replacement API, not a promise that every CAD property -is editable through Python. A client should clone the current value and patch -only fields it understands; unknown fields remain intact. The host can read -all `acadrust::EntityType` variants through `document()`. Its generic write -path accepts existing variants subject to the validation above. Entity -creation and deletion still use `add_entity` and `remove_entity` and are not -part of this transaction request. +## Shape -The host embeds a versioned coverage catalog generated from its traced -`EntityType` registry and `crates/ocs_plugin_api/entity_coverage_policy.json`. -It classifies all 48 variants: 43 canvas kinds, three internal records -(`Block`, `BlockEnd`, `Seqend`), and two opaque fallbacks (`Extended`, -`Unknown`). Each property records its source field, type, optional/sequence -shape, snapshot readability, Python access (`read_write`, `read_only`, or -`unmapped`), and validation status. `unmapped` means the typed snapshot carries -the field but the Python document model has no getter or setter for it. -The v7 transaction path checks changed Point locations, Line endpoints, -Circle/Arc centers, Ray/XLine base points, and Solid/Face3D corners for finite -coordinates. Circle/Arc radii must be finite and positive; Ray/XLine directions -must be unit vectors; Solid normals must be nonzero, its thickness finite, -and Face3D invisible-edge flags limited to four known bits. Insert transactions -validate placement, finite rotation and spacing, nonzero finite scale and normal, -and positive array counts. They reject changes to the referenced block and -attached attribute and sequence records. Those fields report `transaction_geometry` in the -catalog. All 43 canvas kinds also support nonempty `layer` changes through -the same undoable transaction; internal and opaque variants remain read-only -through the document model. The adapter accepts only `layer` patches for -canvas kinds outside its 43 geometry converters. Other writable properties -still report `type_conversion_only`; broader CAD range and cross-property -checks remain outstanding. -The host's `entity_snapshot` helper serializes any typed entity as a detached -JSON value keyed by its catalog variant name. This read path carries every -serializable field, including nested and unmapped values, without granting a -write path for them. The Python adapter exposes its variant payload through -`entity.snapshot`; changing that dictionary does not change the drawing. -It is an inspection view: JSON converts non-finite floating-point values to -null and cannot be used as a lossless replacement entity. +Python runs in RustPython inside a separate plugin process +(`plugins/opencad-python`), so a script cannot crash the editor. It talks to the +host over the versioned IPC in `crates/ocs_plugin_api`. **The host owns the +drawing**: it validates every request, records the undo step, refreshes scene +caches and the shared document view, and marks the document dirty. The Python +adapter is thin: it turns dictionaries into typed values and back, and holds no +CAD rules of its own. Everything below is additive to API v7; a plugin built +without it keeps working, and the default `HostApi` methods refuse. -The bundled first-party adapter under `plugins/opencad-python` currently maps -these entity kinds to Python dictionaries: Point, Line, Circle, Arc, Ellipse, -Polyline, Polyline2D, Polyline3D, LwPolyline, Spline, Text, MText, Ray, XLine, -Solid, Face3D, Insert, Tolerance, Shape, AttributeDefinition, -AttributeEntity, and Hatch. An Insert is created by naming an existing -ordinary block (the host refuses unknown blocks, model or paper space and -containment cycles); the block name cannot be changed afterwards. Attached -attributes stay with the AttributeEntity flow and remain in the raw snapshot. -The legacy Polyline is update-only: create a Polyline2D or Polyline3D. The authoritative per-field mapping is -`plugins/opencad-python/entity_manifest.json`. The generated -mapping excludes fields it cannot represent, including common color, line -weight, transparency, Polyline3D smooth type, and MText background color. -Absent geometry fields are not editable through the Python document model. -A feature test compares the generated Python keys with the -host catalog. The adapter's `experimental-host-model` feature requires API -v7 and builds against the repository-relative `ocs_plugin_api`. RustPython is -loaded inside the separate plugin runner process; OCS does not link the Python -runtime into the editor executable. +## What the host offers -API v7 also exposes a synchronous, ordered selection query and an atomic -selection replacement scoped to the session tab. All handles must exist before -replacement begins, duplicate handles are rejected, and the scripted -replacement keeps exactly the requested handles and order. UI picks continue -to group linked leaders and annotations. The V4 notification channel still broadcasts best-effort -document and selection changes. A new `DrawingChanged` notification carries -the scene epoch even when no shared document view is open. A new `CommandStateChanged` notification -reports transitions of the active interactive command at app message -boundaries, with the tab id and command name (or `None` when it ends). A -noninteractive command that starts and finishes within one message does not -produce an active-state transition. +| Area | `HostApi` entry | What it does | +|---|---|---| +| Read | `document()`, `entity_snapshot` | The full `CadDocument` snapshot; a detached JSON view of any typed entity. | +| Entities | `add_entity`, `remove_entity`, `update_entities_transaction` | Create, delete and replace entities. A transaction validates every replacement first and either commits all of them as one undo step or none. | +| Selection | `selection`, `set_selection` | Ordered, tab-scoped, exact (missing or duplicate handles reject the request). | +| Solids | `solid_operation` | Kernel-backed create, transform, booleans, extrude, region and surface from a profile, picture embedding. Every result is verified to lift back from its ACIS payload without loss; a refusal leaves the operands untouched. | +| Tables | `table_operation` | Layers, text and dimension styles, blocks and their contents, linetypes, layouts (see below). | +| Commands | `run_command` | Drives the real OCS command one step at a time (see below). | +| Settings | `system_variable`, `set_system_variable` | `CLAYER`, `SNAPANG`, and a read-only `CTAB` (the current layout). | -The host's existing `InteractiveCommand` machinery collects either a point or -an entity pick. The bundled v7 adapter exposes a token-based request and -poll API because `PY_RUN` uses a fresh Python interpreter per invocation; a -script cannot suspend in place while the user clicks. Enter and command -cancellation report a cancelled result. The host releases the runner's pick -command when it ends, including Escape cancellation. Drawing, selection, and command -notifications are retained in a bounded 256-event queue per tab between runs -and can be drained with `doc.poll_events()`. If a tab exceeds that bound, the -next poll begins with `{"type": "overflow", "dropped": n}`; scripts should -refresh drawing and selection state when they receive it. Closing a tab clears -its pending events, and a script can poll only its active tab. -Input tokens are bound to the drawing tab that requested the pick. Polling a -token from another tab returns no result and does not consume it. +Entity kinds outside a generated schema still read (handle, kind, layer) and take +`layer` changes; nothing is writable that the host has not validated. -Python `doc.entities[handle]` returns a descriptor for every entity. For kinds -outside the 43-kind generated schema it contains only handle, kind, and layer; -`layer` is editable on canvas kinds. `doc.coverage()` enumerates the -catalog; `doc.coverage("Line")` and `line.coverage` return one entry. For -covered kinds, the coverage object lists actual readable and editable -dictionary keys, plus the unmapped snapshot fields and their types. +## Entity validation + +The host embeds a coverage catalog generated from its traced `EntityType` +registry and `crates/ocs_plugin_api/entity_coverage_policy.json`. It classifies all +48 variants: 43 canvas kinds, three internal records (`Block`, `BlockEnd`, +`Seqend`) and two opaque fallbacks (`Extended`, `Unknown`), and records for each +property its source field, type, shape, snapshot readability, Python access +(`read_write`, `read_only`, `unmapped`) and validation status. + +Creation and edits are validated per kind (finite coordinates, positive radii and +scales, unit directions, non-parallel vectors, in-range flags, reference targets +that exist and have the right kind, and so on). Creation must be valid; an edit +is refused only for problems it introduces, so already-invalid legacy values stay +editable. Derived state is computed by the host, never trusted from a script (MLine +geometry, Dimension measurement, Helix spline, Table merge dimensions, Spline +rational flag, SectionSymbol counts, Viewport id and scale). + +Nested records are checked too. A dictionary for a vertex, edge or face may not +carry an unknown key, and the fields that define its geometry are required +(`entity_manifest.json`, `required_struct_fields`); a typo can no longer turn into +a zero coordinate. Everything else in a nested record defaults when absent. + +Every kind is exercised through the real plugin runner by a per-kind audit that +checks create, read, edit, delete, undo, invalid input with an atomic rollback, +and a DWG and DXF round trip. A kind is `Complete` in the ledger only when all of +those pass. + +## Tables + +`TableOperation` is one additive request with typed variants, executed by +`HostSession::table_operation`. Every refusal is decided **before** the undo step is +recorded, so a failure changes nothing and leaves the history alone. + +- **Layers:** create, modify, rename (entities follow), delete (refused while it + holds objects unless erasing them is asked for), set current. Layer `0`, + `Defpoints` and the current layer are protected. +- **Text and dimension styles:** create, modify, rename, delete, set current. Dimension + style fields travel as a validated JSON object; handles, xref fields and the name are + host-managed. Renaming a text style updates the dimension styles that name it. + `Standard` and any style that is current or in use are protected. +- **Blocks:** create from entities (copied in shifted by the base point, optionally + erasing the originals), modify, rename (inserts follow), delete (refused while + referenced), and add an entity to a definition through the normal validation path. + A block may not nest itself, directly or through other blocks. +- **Linetypes:** simple dash, gap and dot patterns; rename follows layers and + entities; delete is refused while anything uses it. +- **Layouts:** create with the default page setup and sheet viewport, rename, delete, + switch (through OCS's own layout switch), and page setup (paper size, rotation, + custom scale). `Model` is protected. + +Style and layout operations act on the active document, because they use the +application's shared style and view machinery. + +## Commands + +`run_command` takes a `CommandRequest` (`Run`, `Start`, `Point`, `Text`, `Token`, +`Entity`, `Selection`, `Enter`, `Cancel`) and answers with a `CommandOutcome`: +whether the command is done or waiting, its prompt, what input it accepts, its +keyword options, how many entities it added, tokens nobody asked for, and any +error. It uses the same primitives as the automation channel and finishes each +step synchronously on the host thread, so a step never waits for anything that has +to arrive from outside; an earlier fire-and-forget replay could hang, this does not. + +Guards: refused while another command is active or off the active tab; refused for +commands that could end the session or re-enter Python (`QUIT`, `EXIT`, `CLOSE*`, +`NEW`, `QNEW`, `OPEN`, `SAVE*`, `RECOVER`, `SCRIPT`, `RUNSCRIPT`, `PY_*`); an editor or +dialog a command opens is reported and closed by `Cancel`. It does not consult the +automation on/off switch, which governs the external MCP and serve channels; a script +is something the user chose to run. The Python layer cancels any step sequence that +leaves a command waiting. + +## Python surface + +`ocs.active_document` exposes `entities`, `create_entity`, `delete_entity`, +`transaction`, `selection`, `solids`, `layers`, `text_styles`, `dim_styles`, `blocks`, +`linetypes`, `layouts`, `command`, `start_command` and `modify` (offset, trim, extend, +fillet, chamfer, move, copy, rotate, scale, mirror, erase, rectangular, polar, path and +3-D arrays, explode, join, break, stretch, lengthen and polyline edits). `doc.coverage()` +lists the catalog, and `doc.entities[handle].coverage` one entry, with the readable and +editable keys and the fields the typed snapshot carries but the model does not map. + +Interactive picks use a token-based request and poll API because `PY_RUN` uses a +fresh interpreter per invocation and a script cannot suspend while the user clicks. +Drawing, selection and command notifications are kept in a bounded 256-event queue +per tab between runs and drained with `doc.poll_events()`; an overflow is reported +as `{"type": "overflow", "dropped": n}`. Tokens are bound to the tab that asked. + +## Known limits + +- Twelve of the 43 canvas kinds are not yet `Complete`; each has a named blocker in the + ledger. Seven are cadcodec DXF bugs whose fixes are submitted upstream but not yet adopted. +- The DXF codec also drops some table properties (text style generation flags, block + descriptions, a dimension style's text-style name) and mis-scales one angle; these are + pinned by canaries and listed in `cadcodec-reader-gaps.md`. +- Not available from a script: complex (text and shape) linetypes, plot devices and named + page setups, PEDIT's fit and spline options, interactive-only commands such as HATCH, and + a setting to turn script-driven commands off. +- A command step nests inside the editor's message handling; in a debug build a deep step + (PEDIT converting a line to a polyline) needs more than 2 MiB of stack, which the + application's 8 MiB main thread provides and a release build needs far less of. diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index c02694cac..28ed53c53 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -117,8 +117,8 @@ expression, not statements). Phases 1.2 (read), 1.3 (2D write) and part of | `ocs.read_record(handle, app_name)` | Returns `{app_name, values}` for the XDATA record `app_name` on `handle`, or `None` if the entity or the record doesn't exist. See "XDATA" below for the `values` shape. | | `ocs.write_record(handle, app_name, values)` | Attaches an XDATA record to `handle` for `app_name`, replacing any existing record for that application. Raises if `handle` doesn't exist. | | `ocs.remove_record(handle, app_name)` | Removes the XDATA record for `app_name` from `handle`, if any. Returns `True` if a record was actually removed. | -| `ocs.command(cmd)` | Experimental only; absent from the portable default build. Requires unreleased host API support and can hang on OCS 2026.37. See historical notes below. | -| `ocs.select(handles)` | Experimental only; absent from the portable default build. Requires unreleased host API support. | +| `ocs.command(cmd)` | Superseded. Only the `experimental-command-replay` build has it (the old fire-and-forget replay). Use `doc.command`, `doc.start_command` and `doc.modify` from the document model (see "Running OCS commands" below). | +| `ocs.select(handles)` | Replaces the selection with exactly these handles, in order (document-model build); `doc.selection = [...]` is the same. | | `ocs.system_variable(name)` / `ocs.set_system_variable(name, value)` | Development-only `experimental-host-settings` feature. Reads or sets host-managed CLAYER (text) and SNAPANG (degrees) without nested command dispatch. Requires the Felix OCS fork's `plugin/host-model-api` branch; absent from the portable default build. | `add_line`/`add_circle`/`add_arc` take plain Python numbers — @@ -325,10 +325,13 @@ is the raw single step behind all of this. ### Historical command-replay experiment (not in the default build) -The following records an earlier experiment. The bundled build does **not** -expose `ocs.command()` or `ocs.select()`. An OCS 2026.37 diagnostic found -nested `ocs.command("CLAYER ...")` could hang, so these -examples are not instructions to run against the released host. +The following records an earlier experiment and is kept for the findings, not as +instructions. It used a fire-and-forget `ocs.command()` that replayed a line through the +message loop; on OCS 2026.37 that could hang. It has been superseded by +`doc.command` / `doc.start_command` (see "Running OCS commands" above), which run the +real command synchronously through the host's `run_command` step API and were exercised +against every command tried without a hang. The prompt-sequence findings below (selection +prompts, `ALIGN`'s extra Enters) still describe how the commands behave. **`ocs.command()` + `ocs.select()` together can apply a real, persistent constraint, entirely from a script** — this required two host changes @@ -504,37 +507,12 @@ Script…` (above) is the most a plugin can do today: reuse the host's own native file picker via the existing `PluginFileDialog` event, which was already there for other plugins to import files. -## lisp2py — AutoLISP macro transpiler +## AutoLISP migration -`../lisp2py/` (an independent sibling workspace — zero dependency on this -crate or `ocs_plugin_api`) is a **best-effort, offline, source-to-source** -translator from AutoLISP (`.lsp`) to the `ocs.*` Python dialect documented -above. Not an interpreter: it never executes AutoLISP, and its output is a -draft a human reviews and finishes, then runs through `PY_RUN` like any -other script. This is Phase 2.2/2.3 of `ROADMAP.md` — see -[`../lisp2py/README.md`](../lisp2py/README.md) -for the full pipeline, the corpus-measured builtin coverage, and every -known limitation; this is just the quick-start. - -```sh -cargo run --manifest-path ../lisp2py/Cargo.toml --bin ocs-lisp2py -- macro.lsp > macro.py -# then, inside OCS: -PY_RUN macro.py -``` - -(Or pipe source on stdin: `cat macro.lsp | cargo run --manifest-path ../lisp2py/Cargo.toml --bin -ocs-lisp2py -- > macro.py`.) - -Anything the translator can't confidently map — an unrecognized builtin, a -construct with no Python equivalent — becomes a `_todo_manual_port(...)` -call in the generated file (raises `NotImplementedError` if actually run) -plus a warning printed to stderr, never a silent guess. `vla-*`/`vlax-*` -(ActiveX/COM) and `vlr-*` (reactors) are explicitly out of scope, for the -same reason as everywhere else in this plugin's design: COM doesn't run -under AutoCAD for Mac either. Builtin coverage was widened using measured -call-frequency data from a real AutoLISP corpus sample rather than -guesswork, biased toward 2D geometry (`angle`/`distance`/`polar`) over -3D/solid-modeling per current project priority. +An AutoLISP-to-Python translator is a separate project and is not part of this +repository: scripts written for this plugin use the `ocs` module and the document +model described above, and a translated macro would run through `PY_RUN` like any +other script. Nothing in the plugin depends on it. ## XDATA diff --git a/plugins/opencad-python/README.md b/plugins/opencad-python/README.md index 94a7796f5..cf29cf2f9 100644 --- a/plugins/opencad-python/README.md +++ b/plugins/opencad-python/README.md @@ -17,13 +17,22 @@ found while building this: `PLUGIN.md` in this directory. ## Status The API v7 build provides `PY_EVAL`/`PY_RUN`, the read/write `ocs` module, and -the ribbon integration; these have live OCS 2026.37 tests. Experimental -`ocs.command()`/`ocs.select()` constraint work -is documented in `PLUGIN.md` and `examples/example.py`, but those functions -are not in the default build: their host APIs are unreleased, and nested -command replay can hang. The general document model is built through the -`experimental-host-model` feature until that name is retired in a later API -cleanup. +the ribbon integration. On top of that, the `experimental-host-model` feature +(the name stays until a later API cleanup; the staged bundled plugin is built with +it) adds a general Python document model over the host API: + +- **Entities:** create, read, edit, delete and undo for all 43 canvas kinds + (41 creatable), validated by the host, with per-kind DWG/DXF round-trip audits. +- **Drawing tables:** `doc.layers`, `text_styles`, `dim_styles`, `blocks` (and their + contents), `linetypes` and `layouts`, plus kernel-backed solids (`doc.solids`). +- **Commands:** `doc.command`, `doc.start_command` and `doc.modify` (offset, trim, + extend, fillet, chamfer, move, copy, rotate, scale, mirror, arrays, explode, join, + break, stretch, lengthen, PEDIT polyline edits) drive the real OCS commands. + +The design and guarantees are in `../../docs/plugin-host-model.md`, the per-kind +evidence in `../../docs/plugin-host-model-coverage-ledger.md`, and the script-facing +reference in `PLUGIN.md`. The older experimental `ocs.command()` replay is +superseded and not in the default build. ## Files diff --git a/plugins/opencad-python/ROADMAP.md b/plugins/opencad-python/ROADMAP.md index 1d13796d7..6077b769a 100644 --- a/plugins/opencad-python/ROADMAP.md +++ b/plugins/opencad-python/ROADMAP.md @@ -1,13 +1,10 @@ # Open CAD Studio — Python Scripting & AutoLISP Migration Plan -**Status:** Phase 1 done and host-verified; Phase 2 (the `lisp2py` -transpiler) has progressed well past what's checked off below — see -[`../lisp2py/README.md`](../lisp2py/README.md) and -[`../../PLUGIN_DEVELOPMENT_RECIPE.md`](../../PLUGIN_DEVELOPMENT_RECIPE.md) for -current status. Kept here as the original planning document and Phase 1 -build log, moved from `OpenCADStudio`'s `docs/` (where it was originally -drafted, before this repo held the plugin work) unchanged except for -cross-repo reference fixes below. +**Status:** Phase 1 is done and host-verified, and the general document model that +followed (entities, drawing tables, block contents and the command runner) is described in +[`../../docs/plugin-host-model.md`](../../docs/plugin-host-model.md). Phase 2 (an AutoLISP +translator) is a separate project that is not part of this repository. This file is kept as +the original planning document and Phase 1 build log. **Author:** Open CAD Studio contributors **Date:** September 2026 @@ -305,8 +302,7 @@ cost. ## Phase 2 — AutoLISP → Python parser/translator -**See [`../lisp2py/`](../lisp2py) — this phase has its own crate, README, -and builtin-coverage writeup now.** The sub-goals below are the original +**This phase lives in a separate project that is not part of this repository.** The sub-goals below are the original scoping; §2.2/2.3 shipped with substantially wider builtin coverage than first planned (measured from a real AutoLISP corpus sample, not just the constructs listed here). @@ -347,7 +343,7 @@ them). - [x] `getvar`/`setvar` — confirmed no host system-variable surface exists; documented as unsupported rather than guessed at. - [x] Widened well past this original list via measured corpus frequency — - see `lisp2py`'s own README for the full table. + see that project's own README for the full table. ### 2.4 — Validation loop From a25ae4f8c0fe879feb8060c20bf2b373bd6177df Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 18:31:23 +0200 Subject: [PATCH 60/76] chore: clear compiler warnings introduced by the Python host work Drop two unneeded mut bindings in tests and mark the generated dict/enum converters allow(dead_code), since a kind can leave one unused. Co-Authored-By: Claude Sonnet 5 --- plugins/opencad-python/build/generate.rs | 3 +++ src/app/plugin_host.rs | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index 88043c48e..716bc8566 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -587,6 +587,7 @@ fn gen_unit_enum(name: &str, info: &TypeInfo) -> String { {name_arms} }} }} +#[allow(dead_code)] fn str_to_{snake}(value: String, vm: &VirtualMachine) -> PyResult<{path}> {{ match value.as_str() {{ {parse_arms} other => Err(vm.new_value_error(format!("ocs: unsupported {name} value: {{other}}"))), @@ -656,6 +657,7 @@ fn gen_tagged_enum(name: &str, info: &TypeInfo, registry: &TypeRegistry) -> Stri {to_arms} }} }} +#[allow(dead_code)] fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}> {{ let dict = value.try_into_value::(vm)?; let kind = dict.get_item("kind", vm)?.try_into_value::(vm)?; @@ -732,6 +734,7 @@ fn gen_struct(name: &str, info: &TypeInfo, registry: &TypeRegistry, required: &[ {to_dict_body} Ok(dict.into()) }} +#[allow(dead_code)] fn dict_to_{snake}(value: PyObjectRef, vm: &VirtualMachine) -> PyResult<{path}> {{ let dict = value.try_into_value::(vm)?; let allowed: &[&str] = &[{allowed_list}]; diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index a94c9f01c..6f56798df 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -7100,7 +7100,7 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); app.tabs[0].is_start = false; let mut host = HostSession::new(&mut app, 0); - let mut object = |host: &mut HostSession<'_>, data: ClassObjectData| { + let object = |host: &mut HostSession<'_>, data: ClassObjectData| { let handle = host.document_mut().allocate_handle(); let mut class_object = ClassObject::new(data); class_object.handle = handle; @@ -7340,7 +7340,7 @@ mod tests { std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), ).unwrap(); let dir = std::env::temp_dir(); - let mut run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { let script = dir.join(format!("ocs_layers_{tag}_{}.py", std::process::id())); std::fs::write(&script, format!(concat!( "def P(x, y, z): return {{'x': x, 'y': y, 'z': z}}\n", From 2ed41586de51acbda5a2d0dde03da45f35df7e67 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 18:48:27 +0200 Subject: [PATCH 61/76] feat: SCRIPTCOMMANDS setting to turn script-driven commands off A saved user preference (on by default) toggled by the SCRIPTCOMMANDS command gates HostApi::run_command; Cancel stays allowed so a waiting command is never stranded. The runner refuses the SCRIPTCOMMANDS command itself, so a script cannot change it. Documents why no helper-thread or stack-growing dependency was added: native builds link with at least 8 MiB, the nesting depth is fixed, and the deepest step found needs 2 to 4 MiB in a debug build. Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 20 +++++++++++- docs/plugin-host-model.md | 23 +++++++------ plugins/opencad-python/PLUGIN.md | 10 ++++-- src/app/commands/styleprops.rs | 19 +++++++++++ src/app/mod.rs | 3 ++ src/app/plugin_host.rs | 40 ++++++++++++++++++++++- src/app/settings.rs | 18 ++++++++++ src/app/update/file.rs | 2 ++ 8 files changed, 120 insertions(+), 15 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index fc5037976..d861fe9e7 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -9,7 +9,7 @@ Build a general Python document model over the OCS host API so a script can cont - **Entities:** explicit writes for all 43 canvas kinds (41 creatable; the legacy `Polyline` and `Body` are update-only). **31 kinds are `Complete`** (every C/R/E/D/U/I/W/V/P gate passes). The remaining kinds are integration-tested and held short of `Complete` by a named blocker in the ledger: seven by cadcodec DXF bugs (fixes submitted as [cadcodec#48](https://github.com/HakanSeven12/cadcodec/pull/48), not yet merged, so OCS still pins the unfixed revision), Polyline3D by the DWG format, Polyline2D by a kind change on DXF save, RasterImage by an image-reactor structure that cannot be verified, and Body by having no creation path. - **Drawing tables:** layers, text and dimension styles, blocks and their contents, linetypes and layouts, through the additive `TableOperation` request (see the log entries dated 2026-09-21). - **Commands:** `HostApi::run_command` drives the real OCS commands step by step; `doc.command`, `doc.start_command` and `doc.modify` build on it. -- **Known open items:** the unfiled cadcodec findings (`cadcodec-reader-gaps.md`), complex (text and shape) linetypes, plot devices and named page setups, PEDIT's remaining options, and an on/off setting for script-driven commands. +- **Known open items:** the unfiled cadcodec findings (`cadcodec-reader-gaps.md`), complex (text and shape) linetypes, plot devices and named page setups, and PEDIT's remaining options. Repositories: @@ -302,3 +302,21 @@ step needs between 2 and 4 MiB of stack in a debug build, more than a test threa application's main thread has 8 MiB, and a release build uses far less. Not wrapped: PEDIT fit, spline, decurve, linetype generation and vertex editing. +### 2026-09-21: guardrails (`SCRIPTCOMMANDS`, stack) + +**On/off setting.** A saved user preference, `UserSettings::script_commands` (on by default, so an existing config keeps +scripts allowed), toggled by the `SCRIPTCOMMANDS [0|1]` command, gates `HostApi::run_command`: while it is off every `Run`, +`Start` and input request is refused (`Cancel` stays allowed so a waiting command is never stranded) with a message that says how to turn it back on. The runner refuses the command +itself (it joins `QUIT`, `NEW`, `PY_*` and the rest on the denylist), so a script cannot switch it. It is a command and a +persisted flag only; there is no Options-page control, which would need locale and layout work in files outside this feature. +Evidence: `script_commands_setting_gates_the_runner` (default on; a script cannot change it; the user turns it off and nothing +runs, `Run` and `Start` both refused, no entity added; back on and it runs) and `script_commands_default_on_and_survive_a_round_trip`. +Checked that the test did not write the setting into the real user config. + +**Stack.** The planned large-stack thread for command steps was not built, on the evidence: the native builds already link with +at least 8 MiB (`.cargo/config.toml` raises Windows to 16 MiB), the deepest step found (PEDIT converting a line to a polyline) +needs 2 to 4 MiB in a debug build, and the nesting depth is fixed because `PY_*` is refused, so it cannot recurse. Moving the step +to a helper thread would be unsound here (the editor state is not `Send` and some of it has thread affinity), and the alternative, +a stack-growing dependency such as `stacker`, adds a crate to H7's tree to guard a case that cannot occur. The 64 MiB thread in the +PEDIT audit stays, because a test thread gets only 2 MiB. + diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index 76a7758f6..d010baf6f 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -96,11 +96,14 @@ to arrive from outside; an earlier fire-and-forget replay could hang, this does Guards: refused while another command is active or off the active tab; refused for commands that could end the session or re-enter Python (`QUIT`, `EXIT`, `CLOSE*`, -`NEW`, `QNEW`, `OPEN`, `SAVE*`, `RECOVER`, `SCRIPT`, `RUNSCRIPT`, `PY_*`); an editor or -dialog a command opens is reported and closed by `Cancel`. It does not consult the -automation on/off switch, which governs the external MCP and serve channels; a script -is something the user chose to run. The Python layer cancels any step sequence that -leaves a command waiting. +`NEW`, `QNEW`, `OPEN`, `SAVE*`, `RECOVER`, `SCRIPT`, `RUNSCRIPT`, `SCRIPTCOMMANDS`, `PY_*`); an editor or +dialog a command opens is reported and closed by `Cancel`. The user can turn the whole +facility off with the saved `SCRIPTCOMMANDS` preference (on by default; `SCRIPTCOMMANDS 0` +at the command line): while it is off, every request except `Cancel` is refused (so a command a script left +waiting can still be closed). A script cannot change +it, because the runner refuses that command. The runner does not consult the automation +on/off switch, which governs the external MCP and serve channels. The Python layer cancels +any step sequence that leaves a command waiting. ## Python surface @@ -126,8 +129,8 @@ as `{"type": "overflow", "dropped": n}`. Tokens are bound to the tab that asked. descriptions, a dimension style's text-style name) and mis-scales one angle; these are pinned by canaries and listed in `cadcodec-reader-gaps.md`. - Not available from a script: complex (text and shape) linetypes, plot devices and named - page setups, PEDIT's fit and spline options, interactive-only commands such as HATCH, and - a setting to turn script-driven commands off. -- A command step nests inside the editor's message handling; in a debug build a deep step - (PEDIT converting a line to a polyline) needs more than 2 MiB of stack, which the - application's 8 MiB main thread provides and a release build needs far less of. + page setups, PEDIT's fit and spline options, and interactive-only commands such as HATCH. +- A command step nests inside the editor's message handling. In a debug build a deep step + (PEDIT converting a line to a polyline) needs between 2 and 4 MiB of stack; the native + builds link with at least 8 MiB (16 MiB on Windows, `.cargo/config.toml`), the nesting depth + is fixed rather than recursive (`PY_*` is refused), and a release build uses far less. diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 28ed53c53..4394d47d6 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -312,9 +312,13 @@ points default to the middle of a line, arc or circle and the first vertex of a undone as the editor would undo it. Guards: a command is refused while another is active (cancel it first) or when the -line names a command that could end the session or re-enter Python: `QUIT`, -`EXIT`, `CLOSE`, `CLOSEALL`, `NEW`, `QNEW`, `OPEN`, `SAVE`, `QSAVE`, `SAVEAS`, `SAVEALL`, -`RECOVER`, `SCRIPT`, `RUNSCRIPT` and any `PY_*`. Commands run on the active +line names a command that could end the session, re-enter Python or change this +setting: `QUIT`, `EXIT`, `CLOSE`, `CLOSEALL`, `NEW`, `QNEW`, `OPEN`, `SAVE`, `QSAVE`, +`SAVEAS`, `SAVEALL`, `RECOVER`, `SCRIPT`, `RUNSCRIPT`, `SCRIPTCOMMANDS` and any `PY_*`. +The user can switch the whole facility off with the `SCRIPTCOMMANDS 0` command (`1` +turns it back on, plain `SCRIPTCOMMANDS` shows it); it is a saved preference, on by +default, and while it is off every `doc.command`, `start_command` and `doc.modify` call +raises `RuntimeError`. Only the command line can change it, never a script. Commands run on the active document only. Commands that need an interactive surface a script cannot fill (the rich-text editor, a file dialog) report it in `blocked_by` and are cancelled. PEDIT's fit, spline, decurve and vertex-edit options are not wrapped (reach them with diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 67353d6f8..1c3906ce6 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -2894,6 +2894,25 @@ impl OpenCADStudio { _ => self.command_line.push_error(crate::t!("Requires 0 or 1").as_ref()), } } + cmd if cmd == "SCRIPTCOMMANDS" || cmd.starts_with("SCRIPTCOMMANDS ") => { + // A user setting, deliberately outside the script's reach: it is refused by + // the script command runner, so only the command line can change it. + let arg = cmd.trim_start_matches("SCRIPTCOMMANDS").trim(); + match arg { + "" => {} + "1" | "ON" | "YES" => self.script_commands = true, + "0" | "OFF" | "NO" => self.script_commands = false, + _ => { + self.command_line.push_error("SCRIPTCOMMANDS takes 1 (scripts may run commands) or 0 (they may not)"); + return Some(Task::none()); + } + } + if !arg.is_empty() { + self.persist_settings_if_changed(); + } + let state = if self.script_commands { 1 } else { 0 }; + self.command_line.push_output(&format!("SCRIPTCOMMANDS = {state}")); + } "SAVETIME" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new( diff --git a/src/app/mod.rs b/src/app/mod.rs index 16b77c3cb..ab5d279b2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -616,6 +616,8 @@ pub(super) struct OpenCADStudio { /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. pub savetime_min: i32, + /// SCRIPTCOMMANDS: scripts may run OCS commands (see `UserSettings`). + pub script_commands: bool, /// Persisted default viewport background, restored from settings and applied /// to every drawing tab (new and opened) so a chosen background survives /// restarts (#188). `None` = the built-in dark-grey / off-white defaults. @@ -3956,6 +3958,7 @@ impl OpenCADStudio { constraint_bar_display: 3, constraint_bar_mode: 4095, savetime_min: 10, + script_commands: true, default_bg_color: None, default_paper_bg_color: None, cliprompt_lines: 3, diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 6f56798df..658990381 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -1291,7 +1291,7 @@ impl<'a> HostSession<'a> { let first = line.split_whitespace().next()?.trim_start_matches(['\'', '_']).to_uppercase(); const DENIED: &[&str] = &[ "QUIT", "EXIT", "CLOSE", "CLOSEALL", "NEW", "QNEW", "OPEN", "SAVE", "QSAVE", "SAVEAS", - "SAVEALL", "RECOVER", "SCRIPT", "RUNSCRIPT", + "SAVEALL", "RECOVER", "SCRIPT", "RUNSCRIPT", "SCRIPTCOMMANDS", ]; if DENIED.contains(&first.as_str()) || first.starts_with("PY_") { Some(format!("the command {first} cannot be run from a script")) @@ -1299,6 +1299,11 @@ impl<'a> HostSession<'a> { None } }; + // Cancel stays allowed: if the user turns scripts off mid-run, a command a script left + // waiting must still be closable rather than stranded. + if !self.app.script_commands && !matches!(request, R::Cancel) { + return Err("running commands from a script is turned off (SCRIPTCOMMANDS = 0); turn it on at the command line with SCRIPTCOMMANDS 1".to_owned()); + } let active = self.app.tabs[tab].active_cmd.is_some(); let before = self.document().entities().count() as i64; let error_revision = self.app.command_line.error_revision; @@ -8265,6 +8270,39 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) drop(process); } + /// The user's SCRIPTCOMMANDS setting gates the command runner, and a script cannot change it. + #[test] + fn script_commands_setting_gates_the_runner() { + use ocs_plugin_api::host::CommandRequest as R; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + assert!(app.script_commands, "scripts may run commands by default"); + let run = |app: &mut OpenCADStudio, line: &str| HostSession::new(app, 0).run_command(R::Run { line: line.into() }); + assert!(run(&mut app, "LINE 0,0 10,0").is_ok()); + + // A script cannot touch the setting, on or off. + let refused = run(&mut app, "SCRIPTCOMMANDS 0").unwrap_err(); + assert!(refused.contains("cannot be run from a script"), "{refused}"); + assert!(app.script_commands); + + // The user turns it off at the command line. + let reply = app.automation_op(r#"{"op":"run","cmd":"SCRIPTCOMMANDS 0"}"#); + assert_eq!(reply["ok"], true, "{reply}"); + assert!(!app.script_commands); + let refused = run(&mut app, "LINE 0,0 20,0").unwrap_err(); + assert!(refused.contains("turned off"), "{refused}"); + let refused = HostSession::new(&mut app, 0).run_command(R::Start { name: "LINE".into() }).unwrap_err(); + assert!(refused.contains("turned off"), "{refused}"); + assert_eq!(app.tabs[0].scene.document.entities().count(), 1, "nothing ran while it was off"); + assert!(HostSession::new(&mut app, 0).run_command(R::Cancel).is_ok(), "Cancel is still allowed while it is off"); + + // And back on. + app.automation_op(r#"{"op":"run","cmd":"SCRIPTCOMMANDS 1"}"#); + assert!(app.script_commands); + assert!(run(&mut app, "LINE 0,0 20,0").is_ok()); + assert_eq!(app.tabs[0].scene.document.entities().count(), 2); + } + /// Exploration harness (not a gate): prints how the real commands answer /// each step so the wrappers are written against observed prompts. #[test] diff --git a/src/app/settings.rs b/src/app/settings.rs index 938189196..ddbb07fe9 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -476,6 +476,10 @@ pub struct UserSettings { /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, + /// SCRIPTCOMMANDS: whether a script (the Python plugin) may run OCS commands + /// through the host. On by default; a user turns it off with the + /// `SCRIPTCOMMANDS 0` command, and a script cannot change it. + pub script_commands: bool, /// File type and version used when a new/unsaved drawing is first saved. /// Existing drawings keep their own type and version. pub default_save_format: String, @@ -676,6 +680,7 @@ impl Default for UserSettings { constraint_bar_display: 3, constraint_bar_mode: 4095, savetime_min: 10, + script_commands: true, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), pick_add: true, pick_drag_rect: false, @@ -789,6 +794,19 @@ mod tests { assert_eq!(cfg.settings.savetime_min, 42); } + #[test] + fn script_commands_default_on_and_survive_a_round_trip() { + let missing: crate::app::config::AppConfig = + serde_json::from_str(r#"{"settings": {"pick_add": false}}"#).unwrap(); + assert!(missing.settings.script_commands, "an old config keeps scripts allowed"); + let off: crate::app::config::AppConfig = + serde_json::from_str(r#"{"settings": {"script_commands": false}}"#).unwrap(); + assert!(!off.settings.script_commands); + let text = serde_json::to_string(&off).unwrap(); + let back: crate::app::config::AppConfig = serde_json::from_str(&text).unwrap(); + assert!(!back.settings.script_commands); + } + #[test] fn isometric_drafting_never_persists_or_loads() { // A persisted On used to bring the isoplane crosshair (vertical + diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bb43488b2..2a7c25a35 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -619,6 +619,7 @@ impl OpenCADStudio { constraint_bar_display: self.constraint_bar_display, constraint_bar_mode: self.constraint_bar_mode, savetime_min: self.savetime_min, + script_commands: self.script_commands, default_save_format: self.default_save_format.clone(), pick_add: self.pick_add, pick_drag_rect: self.pick_drag_rect, @@ -723,6 +724,7 @@ impl OpenCADStudio { self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3); self.constraint_bar_mode = s.constraint_bar_mode.clamp(0, 4095); self.savetime_min = s.savetime_min; + self.script_commands = s.script_commands; self.default_save_format = crate::io::canonical_save_format(&s.default_save_format).to_string(); self.pick_add = s.pick_add; From d2c2ef178b43a257de16395f6a3456381b72c4d5 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 18:55:42 +0200 Subject: [PATCH 62/76] docs: record cadcodec PR #51 for the text and dimension style DXF fixes Co-Authored-By: Claude Sonnet 5 --- docs/cadcodec-reader-gaps.md | 10 +++++++--- docs/plugin-host-model-completion-plan.md | 9 +++++++++ plugins/opencad-python/PLUGIN.md | 3 ++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/cadcodec-reader-gaps.md b/docs/cadcodec-reader-gaps.md index f446c121e..7e2f45406 100644 --- a/docs/cadcodec-reader-gaps.md +++ b/docs/cadcodec-reader-gaps.md @@ -199,9 +199,12 @@ These are format constraints or OCS-side issues, not reader bugs: emits group 41 only when `flags.rational` is set, so the host must set that flag when weights are present (fixed in OCS). -### Text and dimension style findings (2026-09-21, not filed) +### Text and dimension style findings (2026-09-21) -Found by `audit_python_text_and_dim_styles_over_real_ipc`; each is pinned by a canary. +Found by `audit_python_text_and_dim_styles_over_real_ipc`; each is pinned by a canary. The first +three are fixed in [HakanSeven12/cadcodec#51](https://github.com/HakanSeven12/cadcodec/pull/51) +(independent of #48); the last two are not filed, because fixing them needs the XDATA layout +AutoCAD expects and that cannot be checked here. - **`STYLE` generation flags:** the DXF writer hard-codes group 71 to 0, so a text style's `flags.backward` and `flags.upside_down` are lost on every DXF save (DWG keeps them). @@ -212,5 +215,6 @@ Found by `audit_python_text_and_dim_styles_over_real_ipc`; each is pinned by a c resolves `dimtxsty` from it, so the name reopens as `Standard` (the handle is right; DWG resolves both). - **`TextStyle::true_type_font`** is never written or read by either codec, so it lives only in memory. - **`BLOCK_RECORD` description:** the DXF codec does not carry `BlockRecord::description` - (found by `audit_python_blocks_over_real_ipc`, pinned by a canary); DWG keeps it. + (found by `audit_python_blocks_over_real_ipc`, pinned by a canary); DWG keeps it. Not filed, for + the same reason as `true_type_font`. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index d861fe9e7..dd1bcc146 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -320,3 +320,12 @@ to a helper thread would be unsound here (the editor state is not `Send` and som a stack-growing dependency such as `stacker`, adds a crate to H7's tree to guard a case that cannot occur. The 64 MiB thread in the PEDIT audit stays, because a test thread gets only 2 MiB. +### 2026-09-21: cadcodec PR #51 (text and dimension style DXF fixes) + +Second small upstream PR, [HakanSeven12/cadcodec#51](https://github.com/HakanSeven12/cadcodec/pull/51): STYLE generation +flags (group 71 was hard-coded to 0), the STYLE oblique angle (group 50 is degrees; it was written and read as raw radians) +and the DIMSTYLE text-style name (resolved from the group 340 handle). Three commits, three regression tests, independent of +#48 (merged locally with it; the combined suite passes). Not included: `true_type_font` and the block description, which need +an XDATA layout that cannot be verified here. OCS still pins the unfixed revision, so the canaries in +`audit_python_text_and_dim_styles_over_real_ipc` still assert the wrong result until #51 is merged and OCS repins. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 4394d47d6..e9fa2de9a 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -181,7 +181,8 @@ positive, and `dimtxsty` must name an existing text style. `Standard` is never renamed or deleted; a style that is current or still referenced is never deleted; a case-only rename is refused. Style operations act on the active document. -Known persistence limits (cadcodec, see `docs/cadcodec-reader-gaps.md`): a DXF +Known persistence limits (cadcodec, see `docs/cadcodec-reader-gaps.md`; the first two are fixed in +an open upstream pull request but OCS has not yet adopted it): a DXF save drops the backward/upside-down flags of a text style and does not restore a dimension style's `dimtxsty` name (the handle link survives); DWG keeps both. From 33dfb21cfbc0c43460c4e72c559a4db00f595778 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 19:01:15 +0200 Subject: [PATCH 63/76] ci: fork build workflow for Linux and Windows with the bundled Python plugin Portable builds (executable plus plugins/opencad.python where the host looks for it) and the real-IPC plugin tests on ubuntu and windows. Started by pushing a fork-build-* tag or by hand; publishes nothing. release.yml stages the plugin only in the macOS bundle. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/fork-build.yml | 121 +++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 .github/workflows/fork-build.yml diff --git a/.github/workflows/fork-build.yml b/.github/workflows/fork-build.yml new file mode 100644 index 000000000..e8944b0b6 --- /dev/null +++ b/.github/workflows/fork-build.yml @@ -0,0 +1,121 @@ +name: Fork build (Linux and Windows) + +# Portable builds of this branch, with the bundled Python plugin staged next to +# the executable (`plugins/opencad.python`, where the host looks for it on Linux +# and Windows). Nothing is published. Start it by pushing a tag named +# `fork-build-` (a tag run uses this file from the tagged commit, so it +# works from any branch), or from the Actions tab once this file is on the default +# branch. It is +# separate from release.yml, which builds the upstream installers (AppImage, +# snap, MSI) and stages the plugin only in the macOS bundle. + +on: + push: + tags: ['fork-build-*'] + workflow_dispatch: + inputs: + run_plugin_tests: + description: Run the real-IPC Python plugin tests on each platform + type: boolean + default: true + +env: + CARGO_TERM_COLOR: always + +jobs: + linux: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + # build.rs measures the distance from the last release tag. + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + plugins/opencad-python + + - name: Install dependencies + run: | + sudo apt-get update -q + sudo apt-get install -y \ + libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ + libxkbcommon-dev libwayland-dev libfontconfig1-dev libfreetype6-dev + + - name: Build + run: cargo build --locked --release + + - name: Stage package + run: | + DIST=dist/OpenCADStudio-linux-x86_64 + mkdir -p "$DIST" + cp target/release/OpenCADStudio "$DIST/" + bash plugins/opencad-python/tools/stage-bundled.sh "$DIST/plugins/opencad.python" + ls -R "$DIST" + + - name: Plugin tests (real IPC) + if: ${{ github.event_name == 'push' || inputs.run_plugin_tests }} + env: + OCS_TEST_PYTHON_PLUGIN: ${{ github.workspace }}/dist/OpenCADStudio-linux-x86_64/plugins/opencad.python/libopencad_python.so + OCS_PLUGIN_RUNNER_EXE: ${{ github.workspace }}/dist/OpenCADStudio-linux-x86_64/OpenCADStudio + run: cargo test --locked --release --lib -- --test-threads=1 python + + - name: Pack + run: tar -C dist -czf OpenCADStudio-linux-x86_64.tar.gz OpenCADStudio-linux-x86_64 + + - uses: actions/upload-artifact@v4 + with: + name: OpenCADStudio-linux-x86_64 + path: OpenCADStudio-linux-x86_64.tar.gz + if-no-files-found: error + retention-days: 14 + + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + plugins/opencad-python + + - name: Build + run: cargo build --locked --release + + - name: Stage package + shell: bash + run: | + DIST=dist/OpenCADStudio-windows-x86_64 + mkdir -p "$DIST" + cp target/release/OpenCADStudio.exe "$DIST/" + bash plugins/opencad-python/tools/stage-bundled.sh "$DIST/plugins/opencad.python" + ls -R "$DIST" + + - name: Plugin tests (real IPC) + if: ${{ github.event_name == 'push' || inputs.run_plugin_tests }} + shell: bash + env: + OCS_TEST_PYTHON_PLUGIN: ${{ github.workspace }}/dist/OpenCADStudio-windows-x86_64/plugins/opencad.python/opencad_python.dll + OCS_PLUGIN_RUNNER_EXE: ${{ github.workspace }}/dist/OpenCADStudio-windows-x86_64/OpenCADStudio.exe + run: cargo test --locked --release --lib -- --test-threads=1 python + + - name: Pack + shell: pwsh + run: Compress-Archive -Path dist/OpenCADStudio-windows-x86_64 -DestinationPath OpenCADStudio-windows-x86_64.zip + + - uses: actions/upload-artifact@v4 + with: + name: OpenCADStudio-windows-x86_64 + path: OpenCADStudio-windows-x86_64.zip + if-no-files-found: error + retention-days: 14 From 20f75c34e16358a0eb9f46c867a424f4a682d885 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 19:56:22 +0200 Subject: [PATCH 64/76] test: pass forward-slash temp paths to generated Python scripts On Windows the temp directory has backslashes, which a Python string literal reads as escape sequences (C:\Users is a unicode escape), so the RasterImage and OLE lifecycle tests could not even create their entity. Windows accepts forward slashes. Found by the first Windows run of the fork build workflow; Linux passed all 60. Co-Authored-By: Claude Sonnet 5 --- src/app/plugin_host.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 658990381..c59a841c9 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -5039,7 +5039,10 @@ mod tests { let _ = std::fs::remove_file(&path); }; - let tmp = std::env::temp_dir().to_string_lossy().into_owned(); + // Substituted into Python string literals: on Windows a backslash path such as + // `C:\Users\...` is read as escape sequences (`\U` is a unicode escape), and Windows + // accepts forward slashes, so hand the script a forward-slash path. + let tmp = std::env::temp_dir().to_string_lossy().replace('\\', "/"); let mut create_script = case.create.replace("TMPDIR", &tmp); // Insert is update-only: the host makes a block and one insert, and the // "create" script only has to exist. From 899ec192858a5d9cb3bc24114d595b591a544eff Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 20:48:58 +0200 Subject: [PATCH 65/76] docs: record the Linux and Windows fork build results Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index dd1bcc146..0db6d742e 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -329,3 +329,15 @@ and the DIMSTYLE text-style name (resolved from the group 340 handle). Three com an XDATA layout that cannot be verified here. OCS still pins the unfixed revision, so the canaries in `audit_python_text_and_dim_styles_over_real_ipc` still assert the wrong result until #51 is merged and OCS repins. +### 2026-09-21: Linux and Windows verification (`fork-build.yml`) + +`.github/workflows/fork-build.yml` builds this branch in release on `ubuntu-22.04` and `windows-latest`, stages the Python plugin +beside the executable (`plugins/opencad.python`, where the host looks on both platforms) and runs the real-IPC plugin tests +against that build. It publishes nothing; a run is started by pushing a `fork-build-*` tag to the fork and leaves a downloadable +package per platform for 14 days. Result: the second run (`fork-build-2`, commit `20f75c34`) passed **60 of 60** plugin tests on +Linux and on Windows. The first run passed 60 of 60 on Linux and 58 of 60 on Windows: the RasterImage and OLE lifecycle tests +substituted the Windows temp directory (`C:\Users\...`) into a Python string literal, where `\U` is a unicode escape. That was a +test-harness path problem, not a product one, and is fixed by handing the scripts a forward-slash path. Not covered: the whole lib +suite on those platforms (only the Python-related tests run), the macOS-only bundle path, and an installer (AppImage or MSI) that +includes the plugin. + From 1f8cbb8128ed1281f810df334133c0a7a9ebf06f Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 21:40:46 +0200 Subject: [PATCH 66/76] ci: Python host check for pull requests on Linux and Windows Runs the plugin API tests, the plugin crate and Python model tests, and the host's real-IPC plugin tests against a freshly staged plugin and built runner, and fails if either environment variable target is missing so the tests cannot skip silently. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/python-host-check.yml | 93 +++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 .github/workflows/python-host-check.yml diff --git a/.github/workflows/python-host-check.yml b/.github/workflows/python-host-check.yml new file mode 100644 index 000000000..bb188b414 --- /dev/null +++ b/.github/workflows/python-host-check.yml @@ -0,0 +1,93 @@ +name: Python host check + +# Checks the scripting host API and the bundled Python plugin on Linux and Windows, +# where the plugin is loaded from `plugins/opencad.python` next to the executable. +# The plugin tests talk to a real plugin process over IPC. They are skipped, and +# report success, unless OCS_TEST_PYTHON_PLUGIN and OCS_PLUGIN_RUNNER_EXE are set, +# so this workflow sets both to the freshly built plugin and executable. + +on: + pull_request: + paths: + - 'crates/ocs_plugin_api/**' + - 'plugins/opencad-python/**' + - 'src/app/plugin_host.rs' + - 'src/app/control/**' + - 'src/app/commands/**' + - 'src/app/settings.rs' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/python-host-check.yml' + push: + tags: ['pr-check-*'] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: linux + os: ubuntu-22.04 + library: libopencad_python.so + exe: OpenCADStudio + - name: windows + os: windows-latest + library: opencad_python.dll + exe: OpenCADStudio.exe + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + with: + key: python-host-check + workspaces: | + . + plugins/opencad-python + + - name: Install dependencies (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update -q + sudo apt-get install -y \ + libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ + libxkbcommon-dev libwayland-dev libfontconfig1-dev libfreetype6-dev + + - name: Plugin API tests + run: cargo test --locked -p ocs_plugin_api --features host + + - name: Stage the bundled plugin + run: bash plugins/opencad-python/tools/stage-bundled.sh "$PWD/work/plugin" --debug + + - name: Build the application (the plugin runner) + run: cargo build --locked --bin OpenCADStudio + + - name: Plugin crate tests + working-directory: plugins/opencad-python + run: cargo test --locked + + - name: Python model unit tests + working-directory: plugins/opencad-python + run: python3 -m unittest discover -s tests + + - name: Host tests over real IPC + env: + OCS_TEST_PYTHON_PLUGIN: ${{ github.workspace }}/work/plugin/${{ matrix.library }} + OCS_PLUGIN_RUNNER_EXE: ${{ github.workspace }}/target/debug/${{ matrix.exe }} + run: | + test -f "$OCS_TEST_PYTHON_PLUGIN" || { echo "staged plugin missing: $OCS_TEST_PYTHON_PLUGIN"; exit 1; } + test -f "$OCS_PLUGIN_RUNNER_EXE" || { echo "runner missing: $OCS_PLUGIN_RUNNER_EXE"; exit 1; } + cargo test --locked --lib -- --test-threads=1 python From f3873eff4be7d4e6aab78507474f4e75ee5f5342 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 22:12:09 +0200 Subject: [PATCH 67/76] docs: record the pull-request check workflow and its first run Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 0db6d742e..87a6377b2 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -341,3 +341,13 @@ test-harness path problem, not a product one, and is fixed by handing the script suite on those platforms (only the Python-related tests run), the macOS-only bundle path, and an installer (AppImage or MSI) that includes the plugin. +### 2026-09-21: pull-request check (`python-host-check.yml`) + +`.github/workflows/python-host-check.yml` runs on `pull_request` (path-filtered to the plugin API, the plugin, the host's plugin and command +files, settings and the lockfile), on a `pr-check-*` tag and by hand, on `ubuntu-22.04` and `windows-latest`. Steps: the plugin API tests +(`--features host`), staging the bundled plugin and building the runner, the plugin crate tests, the Python model unit tests, and the host's +real-IPC tests. It fails outright if the staged plugin or the runner is missing, because those tests otherwise skip and report success. First run +(`pr-check-1`, commit `1f8cbb81`, all green on both platforms): 115 plugin API tests, 20 plugin crate tests, 16 Python model tests and 60 real-IPC +host tests on each. Debug build, so a cold run is about 30 minutes and a cached one much less. The whole lib suite is deliberately not run there: +two unrelated tests fail on upstream `main` in the environments checked (see the earlier notes). + From 41d3613b857edec01023295b91ecf683ca94819c Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 22:16:06 +0200 Subject: [PATCH 68/76] docs: reviewer guide for the scripting host model and Python plugin What it is, how to check it on Linux or Windows without a Mac, the reading order, the small edits to existing code, what each area proves, the decisions that are the maintainer's, and what is deliberately not done. Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model.md | 3 +- docs/plugin-host-review-guide.md | 135 +++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 docs/plugin-host-review-guide.md diff --git a/docs/plugin-host-model.md b/docs/plugin-host-model.md index d010baf6f..417a576ea 100644 --- a/docs/plugin-host-model.md +++ b/docs/plugin-host-model.md @@ -3,7 +3,8 @@ This note describes how a script controls OCS and what the host guarantees. The per-kind evidence is in the [coverage ledger](plugin-host-model-coverage-ledger.md), the increment-by-increment record in the [completion plan](plugin-host-model-completion-plan.md), -and the script-facing reference in `plugins/opencad-python/PLUGIN.md`. +and the script-facing reference in `plugins/opencad-python/PLUGIN.md`. A reviewer new to +the change should start with the [review guide](plugin-host-review-guide.md). ## Shape diff --git a/docs/plugin-host-review-guide.md b/docs/plugin-host-review-guide.md new file mode 100644 index 000000000..0837accaa --- /dev/null +++ b/docs/plugin-host-review-guide.md @@ -0,0 +1,135 @@ +# Reviewer guide: scripting host model and the bundled Python plugin + +This is written for a reviewer who has not followed the work and may not have a Mac. +It says what the change is, how to check it on Linux or Windows in a few minutes, where +it touches existing code, what to read first, and what is deliberately left out. + +## What it is + +The bundled `opencad.python` plugin (RustPython, in its own process) can now control OCS +through the versioned plugin API (v7), not only add lines and circles: + +- **Entities.** Create, read, edit, delete and undo for all 43 canvas kinds (41 creatable), + validated by the host, each with a DWG and DXF round-trip audit. +- **Drawing tables.** Layers, text and dimension styles, blocks and their contents, + linetypes and layouts, and kernel-backed solids. +- **Commands.** Scripts can run OCS's own commands step by step (offset, trim, fillet, + arrays, PEDIT and so on), with guards. + +**The host owns the drawing.** It validates every request, records the undo step and +refuses without changing anything when it cannot honour a request. The Python adapter is a +thin translation layer. The design and its guarantees are in +[plugin-host-model.md](plugin-host-model.md). + +**Size.** 74 files, +30,697 / −178, against the merge base. About 15,000 added lines are the +new plugin crate (including a 3,000-line lockfile and a generator), 5,600 are the API crate +(3,500 of them the entity coverage validator), and 8,800 are in `src/`, of which roughly 6,200 +are tests in `plugin_host.rs`. Existing OCS code changes by **178 deleted lines in total**; +everything else is additive. + +## Check it without a Mac + +Nothing here needs macOS. The plugin tests are headless: they build the plugin and the +application, then spawn the application as the plugin runner and talk to it over the real IPC. +`stage-bundled.sh` already handles Linux (`.so`) and Windows (`.dll`), and the host looks for the +plugin in `plugins/opencad.python/` next to the executable. + +```bash +bash plugins/opencad-python/tools/stage-bundled.sh work/plugin --debug +cargo build --bin OpenCADStudio +OCS_TEST_PYTHON_PLUGIN=work/plugin/libopencad_python.so \ +OCS_PLUGIN_RUNNER_EXE=target/debug/OpenCADStudio \ +cargo test --lib -- --test-threads=1 python +``` + +(`opencad_python.dll` and `OpenCADStudio.exe` on Windows.) Expect 60 passed. **Without the two +environment variables these tests are skipped and still report success**; the workflow below fails +instead of skipping. + +Already run for you: + +| Check | Where | Result | +|---|---|---| +| `python-host-check.yml` on the pull request | Linux and Windows | 115 plugin API tests, 20 plugin tests, 16 Python model tests, 60 host tests over real IPC, on each | +| `fork-build.yml` (release build with the plugin staged) | Linux and Windows | 60 of 60 host tests on each; unsigned portable packages you can download and run | +| Local | macOS | the same, plus the wasm check | + +What has **not** been done: launching the GUI on Linux or Windows (the runners have no display), running +the whole `cargo test --lib` suite there, and building an AppImage or MSI that contains the plugin. Two +unrelated tests, `arc_grips_drive_center_start_and_end_but_not_midpoint` and `fonts_parse_and_resolve`, +fail on pristine `main` in the macOS environment used; they are not part of this change and I do not know +how they behave on Linux or Windows. + +## Reading order + +Read the contract first, then the host, then the adapter. The commits are in dependency order but +several kinds and features are interleaved, so read by area rather than commit by commit. + +1. **The contract: `crates/ocs_plugin_api`.** + - `src/host.rs`: the new types and default-refusing methods: `SolidOperation`, `TableOperation`, + `CommandRequest` and `CommandOutcome`, `update_entities_transaction`, `selection`, `set_selection`, + `solid_operation`, `table_operation`, `run_command`. + - `src/ipc/**`: one additive request and response variant per method. The new variants are appended + after the existing ones so existing wire positions do not move. `v4/protocol.rs` has a bincode round-trip + test for each. + - `src/entity_coverage.rs`, `entity_coverage_policy.json`, `build.rs`: the per-kind validation and the + generated coverage catalog. This is the largest file and the most mechanical; the tests in it are the + rules in executable form. +2. **The host: `src/app/plugin_host.rs`**, lines 1 to about 2,700 (the rest is tests). Sections in order: + scripted-entity normalization (host-derived state), solid operations, `table_operation` (layers, then + styles, blocks, linetypes, layouts), `run_command`, and the `HostApi` delegation at the end. +3. **The adapter: `plugins/opencad-python`.** `entity_manifest.json` and `build/generate.rs` generate the + dict-to-entity conversions from the host's type registry; `src/ocs_module.rs` is the `ocs.*` bindings; + `src/document_model.py` is the script-facing document model; `PLUGIN.md` is the reference. +4. **The small edits to existing OCS files** (this is where behaviour of existing code could change): + + | File | Change | + |---|---| + | `src/plugin/external.rs`, `src/ui/window/plugin_manager.rs` | Bundled-plugin discovery (`bundled_plugins_dir`, `OCS_BUNDLED_PLUGINS_DIR`, a "Bundled with Open CAD Studio" label). A per-user plugin with the same id still overrides the bundled one. | + | `src/plugin/v4_support.rs`, `src/app/update/mod.rs`, `src/app/automation.rs` | New `DrawingChanged` and `CommandStateChanged` notifications; a drawing-changed notification after automation ops. | + | `src/entities/{mline,table,helix,dimension}.rs` | `normalize_scripted_*`: derived state a script must not set (MLine geometry, Table merges, Helix spline, Dimension measurement). Called only from the plugin host. | + | `src/scene/selection.rs`, `src/scene/entity.rs`, `src/app/layers.rs` | `replace_selection_exact`, preserving storage data on entity replacement, `set_current_layer_name`. | + | `src/app/style_ops.rs`, `src/app/control/mod.rs` | Four methods and one function widened to `pub(super)` so the host reuses OCS's own style rename, delete and in-use logic. | + | `src/app/settings.rs`, `src/app/mod.rs`, `src/app/update/file.rs`, `src/app/commands/styleprops.rs` | The `SCRIPTCOMMANDS` preference and command (see the decisions below). | + | `packaging/build_macos_signed.sh` | Stages and signs the plugin in the macOS bundle. | + +## What each area proves + +| Area | Evidence | +|---|---| +| 43 entity kinds | One real-IPC audit per kind (create, read, edit two properties, delete, undo and redo, invalid input with an atomic rollback, canvas geometry, DWG and DXF reopen and re-edit). [The ledger](plugin-host-model-coverage-ledger.md) records each kind's exact evidence or its blocker. | +| Solids | Kernel primitives, moves, booleans, extrusions and regions, each verified to lift back from its ACIS payload; refusals leave operands untouched. | +| Tables | `audit_python_layer_table`, `_text_and_dim_styles`, `_blocks`, `_block_contents`, `_linetypes_and_layouts`: dozens of refusals each, live and DWG and DXF persistence, one-step undo. | +| Commands | `audit_python_modify_wrappers`: exact geometry for every wrapper (offset, trim, extend, fillet, chamfer, arrays, explode, join, break, stretch, lengthen, PEDIT edits), guards, an interactive session, undo. | +| Nested input | `audit_python_nested_input_is_validated`: a mistyped vertex key or a missing location is refused instead of becoming a zero coordinate. | +| Guardrail | `script_commands_setting_gates_the_runner` and a settings round-trip test. | + +## Decisions that are yours + +1. **`run_command` and the automation switch.** Script-driven commands do not consult the automation on/off + switch, which governs the external MCP and serve channels; a script is something the user chose to run. Say + if you would rather they respected it. +2. **`SCRIPTCOMMANDS`.** A saved preference (on by default) toggled by a command turns script-driven commands off + entirely. It has no Options-page control; adding one needs locale and layout work outside this change. +3. **Denylist.** A script cannot run `QUIT`, `EXIT`, `CLOSE*`, `NEW`, `QNEW`, `OPEN`, `SAVE*`, `RECOVER`, + `SCRIPT`, `RUNSCRIPT`, `SCRIPTCOMMANDS` or any `PY_*`, and only one command may be active. Tell me about any + other command that should be refused. +4. **Nesting depth.** A command step runs inside the editor's message handling. It is not recursive, but a debug + build of one step (PEDIT converting a line to a polyline) needs 2 to 4 MiB of stack; the native builds link + with at least 8 MiB (16 MiB on Windows). +5. **Splitting.** If 70 commits is too much, the natural seams are: the API and host core, the entity kinds, + the tables, and the command runner. Tell me and I will cut it that way. + +## What is deliberately not done + +- **Twelve of the 43 kinds are not `Complete`** (see the ledger). Seven are blocked by DXF bugs in + cadcodec, whose fixes are submitted as [cadcodec#48](https://github.com/HakanSeven12/cadcodec/pull/48) and + [#51](https://github.com/HakanSeven12/cadcodec/pull/51). **This change still pins the unfixed cadcodec revision**, + so its canary tests assert the old behaviour; once those merge, the pin moves, the canaries flip and those kinds + can be marked complete. The other five are held by the DWG format, a kind change on DXF save, an image-reactor + structure that cannot be verified, an update-only decision, and a missing creation path. +- Not available from a script: complex (text and shape) linetypes, plot devices and named page setups, PEDIT's + fit and spline options, and interactive-only commands such as HATCH. +- Two DXF gaps are not filed upstream because they need the exact XDATA layout AutoCAD expects (the TrueType + family name and the block description); they are listed in [cadcodec-reader-gaps.md](cadcodec-reader-gaps.md). +- `release.yml` is unchanged: the AppImage, snap and MSI still ship without the plugin. From c7c698eaaded7da2bdb1f67b239c5b99ee228b84 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 22:27:43 +0200 Subject: [PATCH 69/76] docs: refresh the review guide and plan after merging upstream main Co-Authored-By: Claude Sonnet 5 --- docs/plugin-host-model-completion-plan.md | 11 +++++++++-- docs/plugin-host-review-guide.md | 17 ++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 87a6377b2..bc534b0a1 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -348,6 +348,13 @@ files, settings and the lockfile), on a `pr-check-*` tag and by hand, on `ubuntu (`--features host`), staging the bundled plugin and building the runner, the plugin crate tests, the Python model unit tests, and the host's real-IPC tests. It fails outright if the staged plugin or the runner is missing, because those tests otherwise skip and report success. First run (`pr-check-1`, commit `1f8cbb81`, all green on both platforms): 115 plugin API tests, 20 plugin crate tests, 16 Python model tests and 60 real-IPC -host tests on each. Debug build, so a cold run is about 30 minutes and a cached one much less. The whole lib suite is deliberately not run there: -two unrelated tests fail on upstream `main` in the environments checked (see the earlier notes). +host tests on each. Debug build, so a cold run is about 30 minutes and a cached one much less. The whole lib suite is deliberately not run by that workflow, because `main`'s own `Tests` workflow (`cargo test --workspace --locked`, added upstream on 21 September) runs it on every pull request. Against the merged tree that command gives 1,805 passed and 1 failed locally on macOS, `fonts_parse_and_resolve`, which also fails on pristine `main` there. + +### 2026-09-21: merged upstream `main` (25 commits) + +Merged `origin/main` at its head (no conflicts). Upstream added `.github/workflows/ci.yml` (`cargo test --workspace --locked` on Linux for every +pull request, `LC_ALL=C`), removed the Nix files, and added parametric-constraint and viewport-plot changes. Re-verified on the merged tree: the +plugin gate (60 real-IPC host tests, 115 plugin API, 20 plugin crate, 16 Python model), the wasm check, and the upstream workspace command with +`--no-fail-fast` (1,805 passed, 1 failed: `fonts_parse_and_resolve`, which fails on pristine `main` on this Mac too; `arc_grips_drive_center_start_and_end_but_not_midpoint` +now passes, fixed upstream). The change is 75 files, +30,833 / -178 against current `main`. diff --git a/docs/plugin-host-review-guide.md b/docs/plugin-host-review-guide.md index 0837accaa..ad62bd4e4 100644 --- a/docs/plugin-host-review-guide.md +++ b/docs/plugin-host-review-guide.md @@ -21,10 +21,10 @@ refuses without changing anything when it cannot honour a request. The Python ad thin translation layer. The design and its guarantees are in [plugin-host-model.md](plugin-host-model.md). -**Size.** 74 files, +30,697 / −178, against the merge base. About 15,000 added lines are the +**Size.** 75 files, +30,833 / −178, against current `main`. About 15,000 added lines are the new plugin crate (including a 3,000-line lockfile and a generator), 5,600 are the API crate (3,500 of them the entity coverage validator), and 8,800 are in `src/`, of which roughly 6,200 -are tests in `plugin_host.rs`. Existing OCS code changes by **178 deleted lines in total**; +are tests in `plugin_host.rs`. Existing OCS code changes by **178 deleted lines in total** (151 of them outside docs, plugin and API); everything else is additive. ## Check it without a Mac @@ -54,11 +54,14 @@ Already run for you: | `fork-build.yml` (release build with the plugin staged) | Linux and Windows | 60 of 60 host tests on each; unsigned portable packages you can download and run | | Local | macOS | the same, plus the wasm check | -What has **not** been done: launching the GUI on Linux or Windows (the runners have no display), running -the whole `cargo test --lib` suite there, and building an AppImage or MSI that contains the plugin. Two -unrelated tests, `arc_grips_drive_center_start_and_end_but_not_midpoint` and `fonts_parse_and_resolve`, -fail on pristine `main` in the macOS environment used; they are not part of this change and I do not know -how they behave on Linux or Windows. +`main` now has its own `Tests` workflow (`cargo test --workspace --locked` on Linux), which will also run on +this pull request. Run locally on macOS against the merged tree with the same command and `LC_ALL=C` and +`--no-fail-fast`, the whole workspace gives **1,805 passed and 1 failed**. The one failure, +`scene::text::lff::tests::fonts_parse_and_resolve`, also fails on pristine `main` in that environment (it +needs font data) and is not part of this change; I expect it passes on the Linux runner but have not seen that. + +What has **not** been done: launching the GUI on Linux or Windows (the runners have no display), running the +whole workspace suite on Windows, and building an AppImage or MSI that contains the plugin. ## Reading order From 8f68006bea4fdd932dae08cf1e3f935208c2e2fa Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 22:35:09 +0200 Subject: [PATCH 70/76] docs: make the change ready for upstream review Remove personal names, local paths and fork-only process notes from code comments, docs and the plugin manifest (repository now points at the upstream project), and drop the handoff section from the plan. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/ARCHITECTURE.md | 4 +- docs/plugin-host-model-completion-plan.md | 47 +++++++---------------- docs/plugin-host-review-guide.md | 4 +- plugins/opencad-python/PLUGIN.md | 2 +- plugins/opencad-python/ROADMAP.md | 2 +- plugins/opencad-python/build/generate.rs | 2 +- plugins/opencad-python/plugin.toml | 2 +- plugins/opencad-python/src/ocs_module.rs | 8 ++-- 8 files changed, 26 insertions(+), 45 deletions(-) diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index c078173a6..a964cd793 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -50,8 +50,8 @@ The host and each plugin run in separate OS processes. The host re-executes itse - 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 fork currently - supports `CLAYER` and `SNAPANG`. +- 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: diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index bc534b0a1..4fe062015 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -4,7 +4,7 @@ Build a general Python document model over the OCS host API so a script can control OCS: read and change every canvas entity, manage the drawing's tables, and run OCS's own commands. Keep a property-level record of read/write, read-only, snapshot-only, and unsupported fields in the [43-kind coverage ledger](plugin-host-model-coverage-ledger.md). The architecture and guarantees are in [plugin-host-model.md](plugin-host-model.md); the progress log at the end of this file records each increment with its evidence. Do not resume the separate Lisp-to-Python conversion work. -**State as of 21 September 2026.** The working branch is `plugin/host-model-api` (host API v7), backed up on the `felixriestra/OpenCADStudio` fork. The first draft PR, [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391), was closed on 21 September with a note that a more complete version will follow; no new PR will be opened until the work is finished and tested as far as it can be. +**State as of 21 September 2026.** The feature is host API v7. An earlier draft pull request, [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391), covered the first slice of it and was closed in favour of this more complete change. - **Entities:** explicit writes for all 43 canvas kinds (41 creatable; the legacy `Polyline` and `Body` are update-only). **31 kinds are `Complete`** (every C/R/E/D/U/I/W/V/P gate passes). The remaining kinds are integration-tested and held short of `Complete` by a named blocker in the ledger: seven by cadcodec DXF bugs (fixes submitted as [cadcodec#48](https://github.com/HakanSeven12/cadcodec/pull/48), not yet merged, so OCS still pins the unfixed revision), Polyline3D by the DWG format, Polyline2D by a kind change on DXF save, RasterImage by an image-reactor structure that cannot be verified, and Body by having no creation path. - **Drawing tables:** layers, text and dimension styles, blocks and their contents, linetypes and layouts, through the additive `TableOperation` request (see the log entries dated 2026-09-21). @@ -13,8 +13,7 @@ Build a general Python document model over the OCS host API so a script can cont Repositories: -- OCS host: `/Users/felix/Documents/MacApps/OpenCADStudio` -- Bundled Python adapter: `/Users/felix/Documents/MacApps/OpenCADStudio/plugins/opencad-python` +- Bundled Python adapter: `plugins/opencad-python` - Host coverage policy: `crates/ocs_plugin_api/entity_coverage_policy.json` - Host coverage/validation: `crates/ocs_plugin_api/src/entity_coverage.rs` and `build.rs` - Adapter mapping: `plugins/opencad-python/entity_manifest.json`, @@ -23,10 +22,7 @@ Repositories: `plugins/opencad-python/src/document_model.py` - Host design notes: `docs/plugin-host-model.md` -Preserve unrelated untracked files. Host and adapter changes now live in the -same OCS repository and commit. Push the feature branch only to the -`felixriestra/OpenCADStudio` fork, and only when asked. Never push to H7's -repository. Verify both upstream `main` and the fork branch before each push. +Host and adapter changes live in the same repository and are committed together. ## First: complete the originally agreed Phase 8 @@ -102,28 +98,15 @@ The last five may require new engine capabilities rather than only Python conver ## Work pattern for each coding increment 1. Inspect the acadrust entity struct, constructors, private fields, serializer, drawing path, and linked tables/objects. Record the intended editable/read-only/unmapped properties first. -2. Put generic validation and transaction behavior in the OCS fork. Keep the Python adapter thin; add a manifest override only when the generator cannot safely express a field. +2. Put generic validation and transaction behavior in the host. Keep the Python adapter thin; add a manifest override only when the generator cannot safely express a field. 3. Add focused host validation, adapter conversion, document-model, real transaction/undo/redo, IPC, GUI, and DWG/DXF fixture checks. Record test evidence in the ledger, including which checks were manual. 4. Run `cargo test -p ocs_plugin_api --features host --lib` and the relevant app host tests. Run `cargo test --locked --features experimental-host-model --manifest-path plugins/opencad-python/Cargo.toml` plus `python3 -m unittest discover -s plugins/opencad-python/tests -v`. -5. Check `git diff --check` and repository status. Stage only task files, - preserve unrelated untracked files, commit host and adapter changes - together, push `plugin/host-model-api` to the Felix fork, and record the - resulting commit and next unfinished gate in the ledger. - -## Handoff start point - -The 21-kind queue is finished: items 1-14 and 16 are mapped and gated, and items 15 (SectionSymbol) and 17-21 (Region, Body, Solid3D, Surface, Ole2Frame) are recorded as **blocked** with concrete engine or fixture dependencies in the ledger. Leader is done except for its W -gate; when the engine writes DXF group 340, reads group 213 and the DWG -R2010+ text-size question is decided, flip the canary assertions in -`staged_python_leader_lifecycle_over_real_ipc` and mark it `Complete`. -AttributeDefinition remains mapped and integration-tested but open at W; its -real IPC lifecycle now passes against acadrust `7ea4247`, so audit the full -optional-property DXF round trip and close or retain the blocker. Keep the -exact queue above and update statuses with evidence after each increment. +5. Check `git diff --check` and repository status, commit host and adapter changes + together, and record the resulting commit and the next unfinished gate in the ledger. ## Phase 8 progress log @@ -329,17 +312,15 @@ and the DIMSTYLE text-style name (resolved from the group 340 handle). Three com an XDATA layout that cannot be verified here. OCS still pins the unfixed revision, so the canaries in `audit_python_text_and_dim_styles_over_real_ipc` still assert the wrong result until #51 is merged and OCS repins. -### 2026-09-21: Linux and Windows verification (`fork-build.yml`) +### 2026-09-21: Linux and Windows verification -`.github/workflows/fork-build.yml` builds this branch in release on `ubuntu-22.04` and `windows-latest`, stages the Python plugin -beside the executable (`plugins/opencad.python`, where the host looks on both platforms) and runs the real-IPC plugin tests -against that build. It publishes nothing; a run is started by pushing a `fork-build-*` tag to the fork and leaves a downloadable -package per platform for 14 days. Result: the second run (`fork-build-2`, commit `20f75c34`) passed **60 of 60** plugin tests on -Linux and on Windows. The first run passed 60 of 60 on Linux and 58 of 60 on Windows: the RasterImage and OLE lifecycle tests -substituted the Windows temp directory (`C:\Users\...`) into a Python string literal, where `\U` is a unicode escape. That was a -test-harness path problem, not a product one, and is fixed by handing the scripts a forward-slash path. Not covered: the whole lib -suite on those platforms (only the Python-related tests run), the macOS-only bundle path, and an installer (AppImage or MSI) that -includes the plugin. +The change was built in release on Ubuntu and Windows runners with the Python plugin staged beside the executable +(`plugins/opencad.python`, where the host looks for it on both platforms), and the real-IPC plugin tests ran against +that build: **60 of 60 on each** (unsigned portable packages, built by a fork-only workflow that is not part of this change). +The first Windows run failed two tests because the harness substituted the Windows temp directory (`C:\Users\...`) into a +Python string literal, where `\U` is a unicode escape; that was a test-harness path problem, not a product one, and is fixed by +handing the scripts a forward-slash path. Not covered: the whole workspace suite on Windows, the macOS-only bundle path, and an +installer (AppImage or MSI) that includes the plugin. ### 2026-09-21: pull-request check (`python-host-check.yml`) diff --git a/docs/plugin-host-review-guide.md b/docs/plugin-host-review-guide.md index ad62bd4e4..d1b43c2cd 100644 --- a/docs/plugin-host-review-guide.md +++ b/docs/plugin-host-review-guide.md @@ -43,7 +43,7 @@ cargo test --lib -- --test-threads=1 python ``` (`opencad_python.dll` and `OpenCADStudio.exe` on Windows.) Expect 60 passed. **Without the two -environment variables these tests are skipped and still report success**; the workflow below fails +environment variables these tests are skipped and still report success**; `python-host-check.yml` fails instead of skipping. Already run for you: @@ -51,7 +51,7 @@ Already run for you: | Check | Where | Result | |---|---|---| | `python-host-check.yml` on the pull request | Linux and Windows | 115 plugin API tests, 20 plugin tests, 16 Python model tests, 60 host tests over real IPC, on each | -| `fork-build.yml` (release build with the plugin staged) | Linux and Windows | 60 of 60 host tests on each; unsigned portable packages you can download and run | +| Release build with the plugin staged (a fork-only workflow, not in this PR) | Linux and Windows | 60 of 60 host tests on each; unsigned portable packages were produced and can be shared on request | | Local | macOS | the same, plus the wasm check | `main` now has its own `Tests` workflow (`cargo test --workspace --locked` on Linux), which will also run on diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index e9fa2de9a..ef2c33ad1 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -119,7 +119,7 @@ expression, not statements). Phases 1.2 (read), 1.3 (2D write) and part of | `ocs.remove_record(handle, app_name)` | Removes the XDATA record for `app_name` from `handle`, if any. Returns `True` if a record was actually removed. | | `ocs.command(cmd)` | Superseded. Only the `experimental-command-replay` build has it (the old fire-and-forget replay). Use `doc.command`, `doc.start_command` and `doc.modify` from the document model (see "Running OCS commands" below). | | `ocs.select(handles)` | Replaces the selection with exactly these handles, in order (document-model build); `doc.selection = [...]` is the same. | -| `ocs.system_variable(name)` / `ocs.set_system_variable(name, value)` | Development-only `experimental-host-settings` feature. Reads or sets host-managed CLAYER (text) and SNAPANG (degrees) without nested command dispatch. Requires the Felix OCS fork's `plugin/host-model-api` branch; absent from the portable default build. | +| `ocs.system_variable(name)` / `ocs.set_system_variable(name, value)` | Development-only `experimental-host-settings` feature. Reads or sets host-managed CLAYER (text) and SNAPANG (degrees) without nested command dispatch. Needs the host API v7 build that provides `system_variable`; absent from the portable default build. | `add_line`/`add_circle`/`add_arc` take plain Python numbers — `ocs.add_line(0, 0, 10, 10)` works with ints, not just floats. (Needed an diff --git a/plugins/opencad-python/ROADMAP.md b/plugins/opencad-python/ROADMAP.md index 6077b769a..05ad1f503 100644 --- a/plugins/opencad-python/ROADMAP.md +++ b/plugins/opencad-python/ROADMAP.md @@ -24,7 +24,7 @@ needs. | | Host-embedded | Plugin (this plan) | |---|---|---| -| Requires OCS core changes / Hakan's buy-in | Yes | No — ships from an independent repo, installed via the existing Plugin Manager | +| Requires OCS core changes and maintainer sign-off | Yes | No — ships from an independent repo, installed via the existing Plugin Manager | | Crash isolation | Relies on RustPython's memory safety alone | Gets OS-process isolation for free (plugins already run out-of-process) | | Distribution | Bundled into every OCS release, forever | Opt-in install, versioned independently, can iterate fast | | Cost | New maintenance burden on the host | Same ABI-pinning cost every plugin already has (see Phase 1, Risk) | diff --git a/plugins/opencad-python/build/generate.rs b/plugins/opencad-python/build/generate.rs index 716bc8566..1a84d5a10 100644 --- a/plugins/opencad-python/build/generate.rs +++ b/plugins/opencad-python/build/generate.rs @@ -90,7 +90,7 @@ pub struct FieldOverride { #[serde(default)] pub exclude: bool, /// `ocs.add()` errors if this field is missing rather than silently - /// defaulting it to zero/empty (Felix's Phase 1 review: "add should + /// defaulting it to zero/empty (Phase 1 review feedback: "add should /// reject missing required geometry rather than defaulting to zero"). /// Only meaningful on the generic (non-`rust_getter`/`rust_setter`) /// path — `ocs.update()` never requires a field (a merge onto an diff --git a/plugins/opencad-python/plugin.toml b/plugins/opencad-python/plugin.toml index ad4188a5e..ca975ae86 100644 --- a/plugins/opencad-python/plugin.toml +++ b/plugins/opencad-python/plugin.toml @@ -5,7 +5,7 @@ version = "0.1.0" description = "Embedded Python scripting (RustPython) for Open CAD Studio." author = "Felix Riestra" license = "GPL-3.0-only" -repository = "felixriestra/OpenCADStudio" +repository = "HakanSeven12/OpenCADStudio" [opencad] api_version = 7 diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 6a953f8f1..4039d33e1 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -4257,7 +4257,7 @@ mod ocs { // // `get_entity` is deliberately a *new*, separate function rather than a // change to `get()` above: existing scripts already depend on `get()`'s - // small `{handle, kind, layer, point}` shape (Felix's Phase 1 review — + // small `{handle, kind, layer, point}` shape (Phase 1 review feedback — // don't break that compatibility to hand out the fuller schema). /// Full entity schema for `handle` (every field the type registry knows @@ -4552,7 +4552,7 @@ mod ocs { /// Update `handle` by merging `entity`'s keys onto its *current* value — /// a key `entity` doesn't mention is left exactly as it was, not reset to - /// a type default (Felix's Phase 1 review: reconstructing from a partial + /// a type default (Phase 1 review feedback: reconstructing from a partial /// dict could silently drop properties this generic API can't represent /// at all, e.g. color/line-weight/XDATA). Errors if `handle` doesn't /// exist or isn't a supported kind. @@ -5396,7 +5396,7 @@ mod ocs { #[test] fn add_rejects_missing_required_geometry_instead_of_defaulting_to_zero() { with_vm(|vm| { - // Felix's Phase 1 review: a Circle with no center/radius must + // Phase 1 review feedback: a Circle with no center/radius must // error, not silently become a zero-radius circle at the origin. let dict = vm.ctx.new_dict(); dict.set_item("kind", vm.new_pyobj("Circle"), vm).unwrap(); @@ -5571,7 +5571,7 @@ mod ocs { }); } - /// The concrete Phase 1 acceptance test Felix asked for: create, read, + /// The concrete Phase 1 acceptance test: create, read, /// edit, delete, save, and reopen a Line and an Ellipse, with common /// properties preserved — exercised against a real /// `acadrust::CadDocument` and a real DWG round trip (not a fake From 4e1a78d3abafa2ae58807a2b5cdb46fc21a378d5 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 22:35:09 +0200 Subject: [PATCH 71/76] ci: leave the fork-only build workflow out of the pull request Co-Authored-By: Claude Sonnet 5 --- .github/workflows/fork-build.yml | 121 ------------------------------- 1 file changed, 121 deletions(-) delete mode 100644 .github/workflows/fork-build.yml diff --git a/.github/workflows/fork-build.yml b/.github/workflows/fork-build.yml deleted file mode 100644 index e8944b0b6..000000000 --- a/.github/workflows/fork-build.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: Fork build (Linux and Windows) - -# Portable builds of this branch, with the bundled Python plugin staged next to -# the executable (`plugins/opencad.python`, where the host looks for it on Linux -# and Windows). Nothing is published. Start it by pushing a tag named -# `fork-build-` (a tag run uses this file from the tagged commit, so it -# works from any branch), or from the Actions tab once this file is on the default -# branch. It is -# separate from release.yml, which builds the upstream installers (AppImage, -# snap, MSI) and stages the plugin only in the macOS bundle. - -on: - push: - tags: ['fork-build-*'] - workflow_dispatch: - inputs: - run_plugin_tests: - description: Run the real-IPC Python plugin tests on each platform - type: boolean - default: true - -env: - CARGO_TERM_COLOR: always - -jobs: - linux: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v4 - with: - # build.rs measures the distance from the last release tag. - fetch-depth: 0 - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: | - . - plugins/opencad-python - - - name: Install dependencies - run: | - sudo apt-get update -q - sudo apt-get install -y \ - libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ - libxkbcommon-dev libwayland-dev libfontconfig1-dev libfreetype6-dev - - - name: Build - run: cargo build --locked --release - - - name: Stage package - run: | - DIST=dist/OpenCADStudio-linux-x86_64 - mkdir -p "$DIST" - cp target/release/OpenCADStudio "$DIST/" - bash plugins/opencad-python/tools/stage-bundled.sh "$DIST/plugins/opencad.python" - ls -R "$DIST" - - - name: Plugin tests (real IPC) - if: ${{ github.event_name == 'push' || inputs.run_plugin_tests }} - env: - OCS_TEST_PYTHON_PLUGIN: ${{ github.workspace }}/dist/OpenCADStudio-linux-x86_64/plugins/opencad.python/libopencad_python.so - OCS_PLUGIN_RUNNER_EXE: ${{ github.workspace }}/dist/OpenCADStudio-linux-x86_64/OpenCADStudio - run: cargo test --locked --release --lib -- --test-threads=1 python - - - name: Pack - run: tar -C dist -czf OpenCADStudio-linux-x86_64.tar.gz OpenCADStudio-linux-x86_64 - - - uses: actions/upload-artifact@v4 - with: - name: OpenCADStudio-linux-x86_64 - path: OpenCADStudio-linux-x86_64.tar.gz - if-no-files-found: error - retention-days: 14 - - windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: dtolnay/rust-toolchain@stable - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: | - . - plugins/opencad-python - - - name: Build - run: cargo build --locked --release - - - name: Stage package - shell: bash - run: | - DIST=dist/OpenCADStudio-windows-x86_64 - mkdir -p "$DIST" - cp target/release/OpenCADStudio.exe "$DIST/" - bash plugins/opencad-python/tools/stage-bundled.sh "$DIST/plugins/opencad.python" - ls -R "$DIST" - - - name: Plugin tests (real IPC) - if: ${{ github.event_name == 'push' || inputs.run_plugin_tests }} - shell: bash - env: - OCS_TEST_PYTHON_PLUGIN: ${{ github.workspace }}/dist/OpenCADStudio-windows-x86_64/plugins/opencad.python/opencad_python.dll - OCS_PLUGIN_RUNNER_EXE: ${{ github.workspace }}/dist/OpenCADStudio-windows-x86_64/OpenCADStudio.exe - run: cargo test --locked --release --lib -- --test-threads=1 python - - - name: Pack - shell: pwsh - run: Compress-Archive -Path dist/OpenCADStudio-windows-x86_64 -DestinationPath OpenCADStudio-windows-x86_64.zip - - - uses: actions/upload-artifact@v4 - with: - name: OpenCADStudio-windows-x86_64 - path: OpenCADStudio-windows-x86_64.zip - if-no-files-found: error - retention-days: 14 From b8ccd3cd5cd116a0c3a9754fbdb614b058218db8 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 22:35:19 +0200 Subject: [PATCH 72/76] docs: update the change size --- docs/plugin-host-model-completion-plan.md | 2 +- docs/plugin-host-review-guide.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 4fe062015..5f031af0a 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -337,5 +337,5 @@ Merged `origin/main` at its head (no conflicts). Upstream added `.github/workflo pull request, `LC_ALL=C`), removed the Nix files, and added parametric-constraint and viewport-plot changes. Re-verified on the merged tree: the plugin gate (60 real-IPC host tests, 115 plugin API, 20 plugin crate, 16 Python model), the wasm check, and the upstream workspace command with `--no-fail-fast` (1,805 passed, 1 failed: `fonts_parse_and_resolve`, which fails on pristine `main` on this Mac too; `arc_grips_drive_center_start_and_end_but_not_midpoint` -now passes, fixed upstream). The change is 75 files, +30,833 / -178 against current `main`. +now passes, fixed upstream). The change is 74 files, +30,703 / -178 against current `main` (fork-only workflow excluded). diff --git a/docs/plugin-host-review-guide.md b/docs/plugin-host-review-guide.md index d1b43c2cd..a28b55841 100644 --- a/docs/plugin-host-review-guide.md +++ b/docs/plugin-host-review-guide.md @@ -21,7 +21,7 @@ refuses without changing anything when it cannot honour a request. The Python ad thin translation layer. The design and its guarantees are in [plugin-host-model.md](plugin-host-model.md). -**Size.** 75 files, +30,833 / −178, against current `main`. About 15,000 added lines are the +**Size.** 74 files, +30,703 / −178, against current `main`. About 15,000 added lines are the new plugin crate (including a 3,000-line lockfile and a generator), 5,600 are the API crate (3,500 of them the entity coverage validator), and 8,800 are in `src/`, of which roughly 6,200 are tests in `plugin_host.rs`. Existing OCS code changes by **178 deleted lines in total** (151 of them outside docs, plugin and API); From 2489dda5b9b22209b6f306775869e975b6e89c69 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 22:35:39 +0200 Subject: [PATCH 73/76] chore: drop stray .DS_Store files; correct the release workflow note in the guide Co-Authored-By: Claude Sonnet 5 --- crates/.DS_Store | Bin 8196 -> 0 bytes docs/plugin-host-review-guide.md | 6 ++++-- 2 files changed, 4 insertions(+), 2 deletions(-) delete mode 100644 crates/.DS_Store diff --git a/crates/.DS_Store b/crates/.DS_Store deleted file mode 100644 index bc57e5c5760aead09d636e1bf44001717d43cd9f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMO-~d-6s*RLZZtuR95~Lw#Ax7vCyxVJZzP&<cpx0P zdhu8A2RL)adi^@?bY_8pL@?oX(p@v%UE5RjW~cYf0&vTt-Xbsupv@{c^MuWWBI8nP zWh(T{5*_)#7i?jK0W$2>W?P3jU=ElA=72e14*U-eaL<;~zF^q139EMl)^MwOb%%11&o9?Qodvb+#kyV>&6!nwuh!nK?7Z~) z8m>FY$2zaij`gOZcN#g?@fja6WW+{{B-Qb(%k%9><-@h(tL9g=&77Qn{jRaQbd9Q_ zrkqH}qYtr;g7;lU1qGGuW4C@6zBq_0QctInBH&JZx_=uVsbF2E^PvKjL(>3^GtvBJvx)X_?6mHLc2PP)rJPlYXa)ib9 ze{X{|=D?jf@IY6dll=eP#qa;`?D}kBbHE(<_YSyLXSK7;b#!a4FDSWo!up9-ip(n& lJ%x=-k$#_!6aW5U$m1l8r2$Jtj Date: Mon, 21 Sep 2026 22:35:58 +0200 Subject: [PATCH 74/76] chore: untrack docs/.DS_Store Co-Authored-By: Claude Sonnet 5 --- docs/.DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 docs/.DS_Store diff --git a/docs/.DS_Store b/docs/.DS_Store deleted file mode 100644 index c5b9a53370157b9e36dbb4a0a4f3018074a3b5a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK&x;c=6n@#(-R>@8S^Tlb0R{I^aobXK@iLATVe#aM9#ra#GjvC%lcCdY52Y|i zQ3TKaA>u(iiWje5{YwPjOH!R!$E_C;&4c9206Z8$1RRnDYtvyEFbw={4A9=)z+F4WeaoS+f8&1BeZ&W;3^>~< zvm5Yk*N?f6`r3`t&il7JAFh_$220zf2gUhcQWz&7g%~64;|Y_%1&lbvh^dcGoluXs z`TA&&V(gt4@4oEI*pHZJz`tj)yqBI&PWf#8Iv>pN5L$Xx=gutaSIq6z)4iLI&x>_g zYR+PYzw4{#X;Yr7FvOMeAi&iM{YjRp-^&|6jiYR|yZec)RI0~T*HAXs9=Hd5;*Px0 zICH$=J^H%MdF;>Io_{9_C%yW~YdjfwQ4$V?LllH)a{q3W1bpJ~aS~+0*OiKOlwN&% zI_)%D4Xf3jHLPj#QoGZz_S%OF}E34caGWgdf@$bP~q${FyW$KB+#8ZUyma?-I<`Shl zF!lIgYG$ThC``FN+2s<23s&kn77eze=o From 36f35da570e5378f9510adfb97c139e21902f3aa Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 22:36:24 +0200 Subject: [PATCH 75/76] docs: final size figures --- docs/.DS_Store | Bin 0 -> 6148 bytes docs/plugin-host-model-completion-plan.md | 2 +- docs/plugin-host-review-guide.md | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 docs/.DS_Store diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c5b9a53370157b9e36dbb4a0a4f3018074a3b5a7 GIT binary patch literal 6148 zcmeHK&x;c=6n@#(-R>@8S^Tlb0R{I^aobXK@iLATVe#aM9#ra#GjvC%lcCdY52Y|i zQ3TKaA>u(iiWje5{YwPjOH!R!$E_C;&4c9206Z8$1RRnDYtvyEFbw={4A9=)z+F4WeaoS+f8&1BeZ&W;3^>~< zvm5Yk*N?f6`r3`t&il7JAFh_$220zf2gUhcQWz&7g%~64;|Y_%1&lbvh^dcGoluXs z`TA&&V(gt4@4oEI*pHZJz`tj)yqBI&PWf#8Iv>pN5L$Xx=gutaSIq6z)4iLI&x>_g zYR+PYzw4{#X;Yr7FvOMeAi&iM{YjRp-^&|6jiYR|yZec)RI0~T*HAXs9=Hd5;*Px0 zICH$=J^H%MdF;>Io_{9_C%yW~YdjfwQ4$V?LllH)a{q3W1bpJ~aS~+0*OiKOlwN&% zI_)%D4Xf3jHLPj#QoGZz_S%OF}E34caGWgdf@$bP~q${FyW$KB+#8ZUyma?-I<`Shl zF!lIgYG$ThC``FN+2s<23s&kn77eze=o literal 0 HcmV?d00001 diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 5f031af0a..e56953166 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -337,5 +337,5 @@ Merged `origin/main` at its head (no conflicts). Upstream added `.github/workflo pull request, `LC_ALL=C`), removed the Nix files, and added parametric-constraint and viewport-plot changes. Re-verified on the merged tree: the plugin gate (60 real-IPC host tests, 115 plugin API, 20 plugin crate, 16 Python model), the wasm check, and the upstream workspace command with `--no-fail-fast` (1,805 passed, 1 failed: `fonts_parse_and_resolve`, which fails on pristine `main` on this Mac too; `arc_grips_drive_center_start_and_end_but_not_midpoint` -now passes, fixed upstream). The change is 74 files, +30,703 / -178 against current `main` (fork-only workflow excluded). +now passes, fixed upstream). The change is 72 files, +30,705 / -178 against current `main` (fork-only workflow excluded). diff --git a/docs/plugin-host-review-guide.md b/docs/plugin-host-review-guide.md index 71e5c6fb9..bd8aef03a 100644 --- a/docs/plugin-host-review-guide.md +++ b/docs/plugin-host-review-guide.md @@ -21,7 +21,7 @@ refuses without changing anything when it cannot honour a request. The Python ad thin translation layer. The design and its guarantees are in [plugin-host-model.md](plugin-host-model.md). -**Size.** 74 files, +30,703 / −178, against current `main`. About 15,000 added lines are the +**Size.** 72 files, +30,705 / −178, against current `main`. About 15,000 added lines are the new plugin crate (including a 3,000-line lockfile and a generator), 5,600 are the API crate (3,500 of them the entity coverage validator), and 8,800 are in `src/`, of which roughly 6,200 are tests in `plugin_host.rs`. Existing OCS code changes by **178 deleted lines in total** (151 of them outside docs, plugin and API); From fb1d1116cef94d45d403ca567c00f09afae4ec99 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 21 Sep 2026 22:36:34 +0200 Subject: [PATCH 76/76] docs: commit count in the guide --- docs/plugin-host-review-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugin-host-review-guide.md b/docs/plugin-host-review-guide.md index bd8aef03a..d242aa808 100644 --- a/docs/plugin-host-review-guide.md +++ b/docs/plugin-host-review-guide.md @@ -120,7 +120,7 @@ several kinds and features are interleaved, so read by area rather than commit b 4. **Nesting depth.** A command step runs inside the editor's message handling. It is not recursive, but a debug build of one step (PEDIT converting a line to a polyline) needs 2 to 4 MiB of stack; the native builds link with at least 8 MiB (16 MiB on Windows). -5. **Splitting.** If 70 commits is too much, the natural seams are: the API and host core, the entity kinds, +5. **Splitting.** If 79 commits is too much, the natural seams are: the API and host core, the entity kinds, the tables, and the command runner. Tell me and I will cut it that way. ## What is deliberately not done