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 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-21 16:00:26 +02:00
1 parent 6b8056cfeb
commit b7b6e2cef5
12 files changed
+681

No files matched your search

+25
View File
@@ -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<Handle, String> {
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<Handle, String> {
Err("table operations are not supported by this host".to_owned())
}
/// Add a layer to the active document with full initial properties.
/// If an optional property in `config` is `None`, standard CAD defaults are applied.
/// Returns `None` if the layer already exists or `config.name` is invalid.
+12
View File
@@ -482,6 +482,18 @@ impl HostApi for PluginHostApi {
}
}
fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result<Handle, String> {
match self.client.request(PluginRequest::TableOperation { operation }) {
Ok(PluginResponse::TableResult(result)) => {
if result.is_ok() { self.document_cache = OnceCell::new(); }
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected table operation response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
match self.client.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
Ok(PluginResponse::SelectionResult(result)) => result,
@@ -155,6 +155,8 @@ pub enum PluginRequest {
SetSelection { handles: Vec<Handle> },
/// V7 (additive): kernel-backed solid create or transform.
SolidOperation { operation: crate::host::SolidOperation },
/// V7 (additive): drawing table record create/modify/rename/delete.
TableOperation { operation: crate::host::TableOperation },
}
/// Responses the host sends back for `PluginRequest`.
@@ -189,6 +191,7 @@ pub enum PluginResponse {
Selection(Vec<Handle>),
SelectionResult(Result<(), String>),
SolidResult(Result<Handle, String>),
TableResult(Result<Handle, String>),
}
/// Messages sent from the host to the plugin runner.
+1
View File
@@ -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)),
}
}
@@ -796,6 +796,18 @@ impl HostApi for V4PluginHostApi {
}
}
fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result<Handle, String> {
match self.request(PluginRequest::TableOperation { operation }) {
Ok(PluginResponse::TableResult(result)) => {
if result.is_ok() { self.document_cache = OnceCell::new(); }
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected table operation response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
Ok(PluginResponse::SelectionResult(result)) => result,
@@ -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::<PluginRequest>(&bytes).unwrap(),
PluginRequest::TableOperation { operation: decoded } if decoded == operation));
}
for result in [Ok(acadrust::Handle::new(9)), Err("refused".to_owned())] {
let bytes = bincode::serialize(&PluginResponse::TableResult(result.clone())).unwrap();
assert!(matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::TableResult(decoded) if decoded == result));
}
}
#[test]
fn solid_operation_request_and_response_roundtrip() {
use crate::host::{SolidOperation, SolidPrimitive};
+14
View File
@@ -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.
+26
View File
@@ -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**
@@ -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)
+159
View File
@@ -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<ocs_plugin_api::host::LayerConfig> {
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<Option<PyObjectRef>> {
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::<i64>(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::<Vec<i64>>(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::<String>(vm)?);
}
if let Some(value) = present("lineweight")? {
let weight = value.try_into_value::<i64>(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::<bool>(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::<String>(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<u64> {
use ocs_plugin_api::host::TableOperation;
let options = if vm.is_none(&options) {
vm.ctx.new_dict()
} else {
options.try_into_value::<rustpython_vm::builtins::PyDictRef>(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::<String>(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<PyObjectRef> {
use acadrust::types::{LineWeight, Transparency};
let rows = host_ctx::with_host(|host| {
let document = host.document();
let mut counts = std::collections::HashMap::<String, usize>::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::<Vec<_>>()
})
.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<PyObjectRef> {
@@ -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})
+300
View File
@@ -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<Handle, String> {
use ocs_plugin_api::host::TableOperation;
let layer_handle = |doc: &CadDocument, name: &str| -> Result<Handle, String> {
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<Handle> = 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<String, String> {
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<Handle, String> {
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::<Vec<_>>().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<String>, Vec<String>) {
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::<Vec<_>>();
(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<b'))
check('empty', lambda: L.create(' '))
check('badcolor0', lambda: L.create('C1', color=0))
check('badcolorlayer', lambda: L.create('C1', color={'kind': 'ByLayer'}))
check('badltype', lambda: L.create('C2', linetype='Nope'))
check('badweight', lambda: L.create('C3', lineweight=999))
check('badtransp', lambda: L.create('C4', transparency=95))
check('unknownkey', lambda: L.create('C5', colour=1))
check('rename0', lambda: L.rename('0', 'X'))
check('renameclash', lambda: L.rename('Grid', 'STRUCTURE'))
check('renamemissing', lambda: L.rename('Nope', 'X2'))
check('del0', lambda: L.delete('0'))
check('delcurrent', lambda: L.delete(L.current['name']))
check('delused', lambda: L.delete('Structure'))
check('freezecurrent', lambda: L.modify(L.current['name'], frozen=True))
check('modnone', lambda: L.modify('Grid'))
check('modmissing', lambda: L.modify('Nope', off=True))
check('currentmissing', lambda: L.set_current('Nope'))
"#);
let (refused, accepted) = report(host.document());
assert!(accepted.is_empty(), "accepted invalid requests: {accepted:?}");
assert_eq!(refused.len(), 19, "{refused:?}");
let check_state = |label: &str, document: &CadDocument| {
let names: Vec<_> = 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;