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

@@ -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]