forked from 1b-agent/opencadstudio-upstream
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b8056cfeb
commit
b7b6e2cef5
12 files changed
+681
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in new issue
Block a user