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;