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 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-21 17:05:48 +02:00
1 parent b1edc9f578
commit 795aa922b4
8 files changed
+960

No files matched your search

+34
View File
@@ -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**
@@ -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)
+208
View File
@@ -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<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 present = |key: &str| -> PyResult<Option<PyObjectRef>> {
Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v)))
};
let pattern = |value: PyObjectRef| -> PyResult<Vec<f64>> {
let items: Vec<PyObjectRef> = 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::<String>(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::<String>(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<PyObjectRef> {
let rows = host_ctx::with_host(|host| {
let document = host.document();
let mut uses = std::collections::HashMap::<String, usize>::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(&lt.name.to_uppercase()).copied().unwrap_or(0)))
.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 (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<PyObjectRef> = 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<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 present = |key: &str| -> PyResult<Option<PyObjectRef>> {
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::<String>(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<u16> {
let degrees = v.try_into_value::<i64>(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<PyObjectRef> {
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::<u64, usize>::new();
let mut viewports = std::collections::HashMap::<u64, usize>::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::<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 (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]
@@ -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)