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 <[email protected]>
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

+28
View File
@@ -147,6 +147,34 @@ pub enum TableOperation {
/// new entity's handle. Edit and delete members with the ordinary entity
/// operations.
BlockEntityAdd { block: String, entity: EntityType },
/// Create a simple linetype. `pattern` is signed lengths in drawing units:
/// positive is a dash, negative a gap, zero a dot (2-12 elements with at
/// least one dash or dot and one gap). Returns the linetype's handle.
LinetypeCreate { name: String, description: String, pattern: Vec<f64> },
/// Change a simple linetype's description or pattern in place.
LinetypeModify { name: String, description: Option<String>, pattern: Option<Vec<f64>> },
/// Rename a linetype; layers and entities that use it follow.
LinetypeRename { from: String, to: String },
/// Delete a linetype that no layer, entity, dimension style or the current
/// setting uses. `Continuous`, `ByLayer` and `ByBlock` are never deleted.
LinetypeDelete { name: String },
/// Create a paper-space layout with the default page setup and sheet viewport.
LayoutCreate { name: String },
/// Rename a paper-space layout (never `Model`).
LayoutRename { from: String, to: String },
/// Delete a paper-space layout and everything on it (never `Model`).
LayoutDelete { name: String },
/// Switch the active layout (`Model` or a paper-space layout).
LayoutSetCurrent { name: String },
/// Change a paper-space layout's sheet: size in millimetres, rotation in
/// degrees (0, 90, 180 or 270) and a custom plot scale (numerator,
/// denominator).
LayoutSetPage {
name: String,
paper_size: Option<[f64; 2]>,
rotation: Option<u16>,
scale: Option<[f64; 2]>,
},
}
/// The style tables `TableOperation` can rename, delete or make current.
@@ -145,6 +145,15 @@ mod tests {
block: "C".into(),
entity: acadrust::EntityType::Line(acadrust::entities::Line::new()),
},
TableOperation::LinetypeCreate { name: "L".into(), description: "d".into(), pattern: vec![1.0, -1.0] },
TableOperation::LinetypeModify { name: "L".into(), description: None, pattern: Some(vec![2.0, -2.0]) },
TableOperation::LinetypeRename { from: "L".into(), to: "M".into() },
TableOperation::LinetypeDelete { name: "M".into() },
TableOperation::LayoutCreate { name: "S".into() },
TableOperation::LayoutRename { from: "S".into(), to: "T".into() },
TableOperation::LayoutDelete { name: "T".into() },
TableOperation::LayoutSetCurrent { name: "Model".into() },
TableOperation::LayoutSetPage { name: "S".into(), paper_size: Some([420.0, 297.0]), rotation: Some(90), scale: Some([1.0, 50.0]) },
] {
let bytes = bincode::serialize(&PluginRequest::TableOperation { operation: operation.clone() }).unwrap();
assert!(matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
+13
View File
@@ -240,3 +240,16 @@ already do. Evidence: `audit_python_block_contents_over_real_ipc` (9 refusals; c
attribute definition inside a block; nested insert; live/DWG/DXF persistence; undo), IPC and Python unit tests.
Next: linetypes and layouts, then headless commands.
### 2026-09-21: linetypes and layouts (`doc.linetypes`, `doc.layouts`)
`TableOperation` gained `LinetypeCreate/Modify/Rename/Delete` (simple patterns only, standard names taken, rename
follows layers and entities, delete refused while a layer, entity, dimension style or the current setting uses it) and
`LayoutCreate/Rename/Delete/SetCurrent/SetPage` (reusing `add_layout`, the default page setup, the sheet viewport,
`rename_layout` plus the layout dictionary key, `delete_layout`, and the application's own `on_layout_switch`; `Model` is
protected; `set_page` sets paper size, rotation and a custom scale and refreshes the limits). The host system variable
`CTAB` now reports the current layout. Evidence: `audit_python_linetypes_and_layouts_over_real_ipc` (34 refusals; patterns,
modify, rename following layers; page setup; an entity created on a sheet and not in model space; live/DWG/DXF
persistence; deleting the current layout; four-step undo restoring the layouts, their entity and the linetype), IPC
round-trip and Python unit tests. Not covered: complex (text/shape) linetypes, reordering layouts, plot devices and
styles, and page-setup names. Next: headless command execution.
+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)
+520
View File
@@ -1018,6 +1018,7 @@ impl<'a> HostSession<'a> {
self.document().header.current_layer_name.clone(),
)),
"SNAPANG" => Some(HostSettingValue::Number(self.app.snap_angle_deg as f64)),
"CTAB" => Some(HostSettingValue::Text(self.app.tabs[self.tab].scene.current_layout.clone())),
_ => None,
}
}
@@ -1259,10 +1260,377 @@ impl<'a> HostSession<'a> {
| TableOperation::BlockRename { .. }
| TableOperation::BlockDelete { .. }
| TableOperation::BlockEntityAdd { .. }) => self.block_operation(other),
other @ (TableOperation::LinetypeCreate { .. }
| TableOperation::LinetypeModify { .. }
| TableOperation::LinetypeRename { .. }
| TableOperation::LinetypeDelete { .. }) => self.linetype_operation(other),
other @ (TableOperation::LayoutCreate { .. }
| TableOperation::LayoutRename { .. }
| TableOperation::LayoutDelete { .. }
| TableOperation::LayoutSetCurrent { .. }
| TableOperation::LayoutSetPage { .. }) => self.layout_operation(other),
other => self.style_operation(other),
}
}
/// Simple linetype create/modify/rename/delete.
fn linetype_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result<Handle, String> {
use ocs_plugin_api::host::TableOperation;
let protected = |name: &str| {
["CONTINUOUS", "BYLAYER", "BYBLOCK"].contains(&name.trim().to_uppercase().as_str())
};
let check_pattern = |pattern: &[f64]| -> Result<(), String> {
if !(2..=12).contains(&pattern.len()) {
return Err("a linetype pattern needs 2 to 12 elements".to_owned());
}
if pattern.iter().any(|v| !v.is_finite() || v.abs() > 1.0e6) {
return Err("linetype pattern lengths must be finite and within 1e6".to_owned());
}
if !pattern.iter().any(|v| *v >= 0.0) || !pattern.iter().any(|v| *v < 0.0) {
return Err("a linetype pattern needs at least one dash or dot and one gap".to_owned());
}
if pattern.iter().map(|v| v.abs()).sum::<f64>() <= 0.0 {
return Err("a linetype pattern must have a nonzero length".to_owned());
}
Ok(())
};
let build = |lt: &mut acadrust::tables::LineType, pattern: &[f64]| {
lt.elements = pattern
.iter()
.map(|length| acadrust::tables::linetype::LineTypeElement { length: *length, complex: None })
.collect();
lt.pattern_length = pattern.iter().map(|v| v.abs()).sum();
};
let check_description = |text: &str| {
if text.chars().any(|c| c.is_control()) || text.chars().count() > 255 {
Err("a linetype description must be text of at most 255 characters".to_owned())
} else {
Ok(())
}
};
// Loading the standard linetypes first means a name that already exists as a
// standard pattern (Dashed, Center, ...) is seen as taken.
crate::io::linetypes::populate_document(self.document_mut());
match operation {
TableOperation::LinetypeCreate { name, description, pattern } => {
let name = validated_symbol_name(&name, "a linetype name")?;
if self.document().line_types.contains(&name) {
return Err(format!("linetype {name:?} already exists"));
}
check_pattern(&pattern)?;
check_description(&description)?;
self.push_undo("Create linetype");
let mut lt = acadrust::tables::LineType::new(name.clone());
lt.description = description;
build(&mut lt, &pattern);
let handle = self.document_mut().allocate_handle();
lt.handle = handle;
self.document_mut().line_types.add(lt).map_err(|e| format!("the linetype could not be created: {e}"))?;
self.finish_linetype_change(&[name]);
Ok(handle)
}
TableOperation::LinetypeModify { name, description, pattern } => {
let existing = self
.document()
.line_types
.get(name.trim())
.cloned()
.ok_or_else(|| format!("linetype {:?} does not exist", name.trim()))?;
if protected(&existing.name) || existing.xref_dependent {
return Err(format!("linetype {:?} cannot be changed", existing.name));
}
if existing.elements.iter().any(|e| e.complex.is_some()) {
return Err("a linetype with text or shape elements cannot be changed from a script".to_owned());
}
if description.is_none() && pattern.is_none() {
return Err("no linetype properties to change".to_owned());
}
if let Some(text) = &description {
check_description(text)?;
}
if let Some(pattern) = &pattern {
check_pattern(pattern)?;
}
self.push_undo("Modify linetype");
let mut lt = existing.clone();
if let Some(text) = description {
lt.description = text;
}
if let Some(pattern) = &pattern {
build(&mut lt, pattern);
}
if let Some(slot) = self.document_mut().line_types.get_mut(&existing.name) {
*slot = lt;
}
self.finish_linetype_change(&[existing.name.clone()]);
Ok(existing.handle)
}
TableOperation::LinetypeRename { from, to } => {
let existing = self
.document()
.line_types
.get(from.trim())
.cloned()
.ok_or_else(|| format!("linetype {:?} does not exist", from.trim()))?;
if protected(&existing.name) || existing.xref_dependent {
return Err(format!("linetype {:?} cannot be renamed", existing.name));
}
let to = validated_symbol_name(&to, "a linetype name")?;
if existing.name.eq_ignore_ascii_case(&to) {
return Err("the new linetype name matches the current one (a case-only change is not supported)".to_owned());
}
if self.document().line_types.contains(&to) {
return Err(format!("linetype {to:?} already exists"));
}
self.push_undo("Rename linetype");
let old = existing.name.clone();
let handle = existing.handle;
let doc = self.document_mut();
let mut lt = existing;
lt.name = to.clone();
doc.line_types.remove(&old);
doc.line_types.add(lt).map_err(|e| format!("the linetype could not be renamed: {e}"))?;
for layer in doc.layers.iter_mut() {
if layer.line_type.eq_ignore_ascii_case(&old) {
layer.line_type = to.clone();
}
}
for entity in doc.entities_mut() {
if entity.common().linetype.eq_ignore_ascii_case(&old) {
entity.common_mut().linetype = to.clone();
}
}
if doc.header.current_linetype_name.eq_ignore_ascii_case(&old) {
doc.header.current_linetype_name = to.clone();
}
self.finish_linetype_change(&[old, to]);
Ok(handle)
}
TableOperation::LinetypeDelete { name } => {
let existing = self
.document()
.line_types
.get(name.trim())
.cloned()
.ok_or_else(|| format!("linetype {:?} does not exist", name.trim()))?;
if protected(&existing.name) || existing.xref_dependent {
return Err(format!("linetype {:?} cannot be deleted", existing.name));
}
let doc = self.document();
let in_use = doc.header.current_linetype_name.eq_ignore_ascii_case(&existing.name)
|| doc.layers.iter().any(|layer| layer.line_type.eq_ignore_ascii_case(&existing.name))
|| doc.entities().any(|entity| entity.common().linetype.eq_ignore_ascii_case(&existing.name))
|| doc.dim_styles.iter().any(|style| {
[style.dimltex_handle, style.dimltex1_handle, style.dimltex2_handle].contains(&existing.handle)
});
if in_use {
return Err(format!("linetype {:?} is current or still in use", existing.name));
}
self.push_undo("Delete linetype");
self.document_mut().line_types.remove(&existing.name);
self.finish_linetype_change(&[existing.name]);
Ok(existing.handle)
}
_ => Err("unsupported table operation".to_owned()),
}
}
fn finish_linetype_change(&mut self, names: &[String]) {
self.app.tabs[self.tab].dirty = true;
self.app.tabs[self.tab].scene.invalidate_layer_dependencies(names);
self.app.tabs[self.tab].scene.bump_geometry();
self.publish_document_view();
}
/// Paper-space layout operations (create, rename, delete, switch, page).
/// They act on the active document because switching layouts drives the
/// application's view state.
fn layout_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result<Handle, String> {
use acadrust::objects::ObjectType;
use ocs_plugin_api::host::TableOperation;
if self.tab != self.app.active_tab {
return Err("layout operations act on the active document; switch to it first".to_owned());
}
let find = |doc: &CadDocument, name: &str| -> Result<(String, Handle), String> {
doc.objects
.values()
.find_map(|object| match object {
ObjectType::Layout(layout)
if !layout.block_record.is_null() && layout.name.eq_ignore_ascii_case(name) =>
{
Some((layout.name.clone(), layout.handle))
}
_ => None,
})
.ok_or_else(|| format!("layout {name:?} does not exist"))
};
let names_taken = |app: &crate::app::OpenCADStudio, tab: usize, name: &str| {
app.tabs[tab].scene.layout_names().iter().any(|existing| existing.eq_ignore_ascii_case(name))
};
match operation {
TableOperation::LayoutCreate { name } => {
let name = validated_symbol_name(&name, "a layout name")?;
if names_taken(self.app, self.tab, &name) {
return Err(format!("layout {name:?} already exists"));
}
self.push_undo("Create layout");
let handle = self
.document_mut()
.add_layout(&name)
.map_err(|e| format!("the layout could not be created: {e}"))?;
let flags = i16::from(self.document().header.paper_space_linetype_scaling)
| (i16::from(self.document().header.paper_space_limit_check) << 1);
let plot_style = self
.app
.active_plot_style
.as_ref()
.map(|style| style.name.clone())
.unwrap_or_default();
for object in self.document_mut().objects.values_mut() {
if let ObjectType::Layout(layout) = object {
if layout.name == name {
layout.flags = flags;
crate::scene::apply_default_page_setup(layout, &plot_style);
break;
}
}
}
self.app.tabs[self.tab].scene.ensure_sheet_viewport(&name);
self.finish_layout_change();
Ok(handle)
}
TableOperation::LayoutRename { from, to } => {
let (from, handle) = find(self.document(), from.trim())?;
if from.eq_ignore_ascii_case("Model") {
return Err("the Model layout cannot be renamed".to_owned());
}
let to = validated_symbol_name(&to, "a layout name")?;
if to.eq_ignore_ascii_case("Model") {
return Err("a layout cannot be named Model".to_owned());
}
if from.eq_ignore_ascii_case(&to) {
return Err("the new layout name matches the current one (a case-only change is not supported)".to_owned());
}
if names_taken(self.app, self.tab, &to) {
return Err(format!("layout {to:?} already exists"));
}
self.push_undo("Rename layout");
self.app.tabs[self.tab].scene.rename_layout(&from, &to);
// The layout dictionary is keyed by name; keep it in step.
let dict = self.document().header.acad_layout_dict_handle;
if let Some(ObjectType::Dictionary(dictionary)) = self.document_mut().objects.get_mut(&dict) {
for (key, _) in dictionary.entries.iter_mut() {
if *key == from {
*key = to.clone();
}
}
}
if self.app.tabs[self.tab].scene.current_layout == from {
self.app.tabs[self.tab].scene.set_current_layout(to);
}
self.finish_layout_change();
Ok(handle)
}
TableOperation::LayoutDelete { name } => {
let (name, handle) = find(self.document(), name.trim())?;
if name.eq_ignore_ascii_case("Model") {
return Err("the Model layout cannot be deleted".to_owned());
}
self.push_undo("Delete layout");
if self.app.tabs[self.tab].scene.current_layout == name {
let _ = self.app.on_layout_switch("Model".to_owned());
}
if !self.app.tabs[self.tab].scene.delete_layout(&name) {
return Err("the layout could not be deleted".to_owned());
}
self.finish_layout_change();
Ok(handle)
}
TableOperation::LayoutSetCurrent { name } => {
let name = name.trim();
if name.eq_ignore_ascii_case("Model") {
let _ = self.app.on_layout_switch("Model".to_owned());
return Ok(Handle::NULL);
}
let (name, handle) = find(self.document(), name)?;
let _ = self.app.on_layout_switch(name);
self.publish_document_view();
Ok(handle)
}
TableOperation::LayoutSetPage { name, paper_size, rotation, scale } => {
let (name, handle) = find(self.document(), name.trim())?;
if name.eq_ignore_ascii_case("Model") {
return Err("the Model layout has no sheet setup".to_owned());
}
if paper_size.is_none() && rotation.is_none() && scale.is_none() {
return Err("no page setup properties to change".to_owned());
}
if let Some([width, height]) = paper_size {
if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 && width <= 100_000.0 && height <= 100_000.0) {
return Err("the paper size must be positive and at most 100000 mm each way".to_owned());
}
}
let rotation_code = match rotation {
None => None,
Some(0) => Some(acadrust::objects::PlotRotation::None),
Some(90) => Some(acadrust::objects::PlotRotation::Degrees90),
Some(180) => Some(acadrust::objects::PlotRotation::Degrees180),
Some(270) => Some(acadrust::objects::PlotRotation::Degrees270),
Some(_) => return Err("the plot rotation must be 0, 90, 180 or 270 degrees".to_owned()),
};
if let Some([numerator, denominator]) = scale {
if !(numerator.is_finite() && denominator.is_finite() && numerator > 0.0 && denominator > 0.0) {
return Err("the plot scale numerator and denominator must be positive".to_owned());
}
}
self.push_undo("Layout page setup");
for object in self.document_mut().objects.values_mut() {
let ObjectType::Layout(layout) = object else { continue };
if layout.name != name {
continue;
}
if let Some([width, height]) = paper_size {
layout.paper_width = width;
layout.paper_height = height;
layout.paper_size = String::new();
}
if let Some(code) = rotation_code {
layout.plot_rotation = code.to_code();
}
if let Some([numerator, denominator]) = scale {
layout.plot_scale_numerator = numerator;
layout.plot_scale_denominator = denominator;
layout.plot_scale_factor = numerator / denominator;
layout.plot_scale_type = acadrust::objects::ScaledType::CustomScale.to_code();
layout.plot_flags.use_standard_scale = false;
}
let quarter_turn = layout.plot_rotation == acadrust::objects::PlotRotation::Degrees90.to_code()
|| layout.plot_rotation == acadrust::objects::PlotRotation::Degrees270.to_code();
let (x, y) = if quarter_turn {
(layout.paper_height, layout.paper_width)
} else {
(layout.paper_width, layout.paper_height)
};
layout.min_limits = (0.0, 0.0);
layout.max_limits = (x, y);
layout.min_extents = (0.0, 0.0, 0.0);
layout.max_extents = (x, y, 0.0);
break;
}
self.app.tabs[self.tab].scene.invalidate_display_plot_style();
self.finish_layout_change();
Ok(handle)
}
_ => Err("unsupported table operation".to_owned()),
}
}
fn finish_layout_change(&mut self) {
self.app.tabs[self.tab].dirty = true;
self.app.tabs[self.tab].scene.bump_geometry();
self.publish_document_view();
}
/// Block definition operations: create from entities, modify settings,
/// rename (inserts follow) and delete an unreferenced definition.
fn block_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result<Handle, String> {
@@ -7310,6 +7678,158 @@ check('unknown_insert_block', lambda: doc.create_entity('Insert', block_name='Gh
assert_eq!(existing, 3, "undo removed the added member");
}
/// Linetypes and layouts through Python over the real runner: create,
/// modify, rename (layers follow), guarded delete, page setup, entities
/// placed on a layout, DWG/DXF persistence and undo.
#[test]
fn audit_python_linetypes_and_layouts_over_real_ipc() {
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 run = |host: &mut HostSession<'_>, tag: &str, body: &str| {
let script = dir.join(format!("ocs_ltlay_{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\nLT = doc.linetypes\nLY = doc.layouts\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 ' + str(len(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| -> (usize, 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();
(refused.trim().parse().unwrap(), accepted.split_whitespace().map(str::to_owned).collect())
};
run(&mut host, "build", r#"
LT.create('Bracket', [12, -3, 2, -3], 'Phantom test')
LT.create('Dots', [0, -2], 'dots')
LT.modify('Dots', pattern=[0, -4], description='sparse dots')
LT.create('Temp', [1, -1])
L.create('Hidden2', linetype='Bracket')
L.create('Dotted2', linetype='Dots')
LT.rename('Dots', 'Sparse')
LY.create('Sheet1')
LY.create('Sheet2')
LY.set_page('Sheet1', paper_size=(420, 297), rotation=0, scale=(1, 50))
LY.rename('Sheet2', 'Details')
LY.set_current('Sheet1')
doc.create_entity('Circle', center=P(50, 50, 0), radius=5)
LY.set_current('Model')
check('lt_dup', lambda: LT.create('bracket', [1, -1]))
check('lt_standard_dup', lambda: LT.create('Dashed', [1, -1]))
check('lt_badname', lambda: LT.create('a|b', [1, -1]))
check('lt_empty', lambda: LT.create('X1', []))
check('lt_single', lambda: LT.create('X2', [1]))
check('lt_nogap', lambda: LT.create('X3', [1, 2]))
check('lt_nodash', lambda: LT.create('X4', [-1, -2]))
check('lt_nonfinite', lambda: LT.create('X5', [1e999, -1]))
check('lt_toomany', lambda: LT.create('X6', [1, -1] * 7))
check('lt_modnone', lambda: LT.modify('Bracket'))
check('lt_modmissing', lambda: LT.modify('Nope', description='x'))
check('lt_modcontinuous', lambda: LT.modify('Continuous', description='x'))
check('lt_renamecontinuous', lambda: LT.rename('Continuous', 'Solid2'))
check('lt_renameclash', lambda: LT.rename('Temp', 'BRACKET'))
check('lt_renamecase', lambda: LT.rename('Temp', 'TEMP'))
check('lt_delcontinuous', lambda: LT.delete('ByLayer'))
check('lt_delinuse', lambda: LT.delete('Bracket'))
check('lt_delmissing', lambda: LT.delete('Nope'))
check('ly_dup', lambda: LY.create('sheet1'))
check('ly_model', lambda: LY.create('Model'))
check('ly_badname', lambda: LY.create('a<b'))
check('ly_renamemodel', lambda: LY.rename('Model', 'X'))
check('ly_renamemissing', lambda: LY.rename('Nope', 'X'))
check('ly_renameclash', lambda: LY.rename('Details', 'SHEET1'))
check('ly_renamecase', lambda: LY.rename('Details', 'DETAILS'))
check('ly_deletemodel', lambda: LY.delete('Model'))
check('ly_deletemissing', lambda: LY.delete('Nope'))
check('ly_currentmissing', lambda: LY.set_current('Nope'))
check('ly_pagemissing', lambda: LY.set_page('Nope', rotation=0))
check('ly_pagemodel', lambda: LY.set_page('Model', rotation=0))
check('ly_pagenone', lambda: LY.set_page('Sheet1'))
check('ly_badsize', lambda: LY.set_page('Sheet1', paper_size=(0, 10)))
check('ly_badrotation', lambda: LY.set_page('Sheet1', rotation=45))
check('ly_badscale', lambda: LY.set_page('Sheet1', scale=(0, 1)))
"#);
let (refused, accepted) = report(host.document());
assert!(accepted.is_empty(), "accepted invalid requests: {accepted:?}");
assert_eq!(refused, 34);
let check_state = |label: &str, document: &CadDocument| {
assert!(document.line_types.get("Dots").is_none(), "{label}: old linetype name gone");
let phantom = document.line_types.get("Bracket").unwrap_or_else(|| panic!("{label}: Bracket missing"));
let lengths: Vec<f64> = phantom.elements.iter().map(|e| e.length).collect();
assert_eq!(lengths, vec![12.0, -3.0, 2.0, -3.0], "{label}: pattern");
assert_eq!(phantom.pattern_length, 20.0, "{label}");
let sparse = document.line_types.get("Sparse").unwrap_or_else(|| panic!("{label}: Sparse missing"));
assert_eq!(sparse.elements.iter().map(|e| e.length).collect::<Vec<_>>(), vec![0.0, -4.0], "{label}: modified pattern");
assert_eq!(document.layers.get("Dotted2").unwrap().line_type, "Sparse", "{label}: layer followed the rename");
assert_eq!(document.layers.get("Hidden2").unwrap().line_type, "Bracket", "{label}");
let layouts = |name: &str| document.objects.values().find_map(|o| match o {
acadrust::objects::ObjectType::Layout(l) if l.name == name && !l.block_record.is_null() => Some(l.clone()),
_ => None,
});
assert!(layouts("Sheet2").is_none(), "{label}: old layout name gone");
let sheet = layouts("Sheet1").unwrap_or_else(|| panic!("{label}: Sheet1 missing"));
assert_eq!((sheet.paper_width, sheet.paper_height), (420.0, 297.0), "{label}: paper size");
assert_eq!(sheet.plot_rotation, 0, "{label}: rotation");
assert_eq!((sheet.plot_scale_numerator, sheet.plot_scale_denominator), (1.0, 50.0), "{label}: scale");
assert!(layouts("Details").is_some(), "{label}: Details missing");
let on_sheet = document.entities().filter(|e| e.common().owner_handle == sheet.block_record && matches!(e, EntityType::Circle(_))).count();
assert_eq!(on_sheet, 1, "{label}: the circle was created on the sheet");
assert!(!document.entities().any(|e| matches!(e, EntityType::Circle(_)) && document.block_records.iter().any(|r| r.name == "*Model_Space" && r.handle == e.common().owner_handle)),
"{label}: no circle in model space");
};
check_state("live", host.document());
assert_eq!(host.app.tabs[0].scene.current_layout, "Model", "the script switched back to Model");
let dwg = crate::io::load_bytes("ltl.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("ltl.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
check_state("DWG", &dwg);
check_state("DXF", &dxf);
run(&mut host, "destroy", r#"
LY.set_current('Details')
LY.delete('Details')
LY.delete('Sheet1')
LT.delete('Temp')
"#);
let (refused, accepted) = report(host.document());
assert!(accepted.is_empty(), "{accepted:?}");
assert_eq!(refused, 0);
let document = host.document();
assert!(document.line_types.get("Temp").is_none());
assert!(!document.objects.values().any(|o| matches!(o, acadrust::objects::ObjectType::Layout(l) if l.name == "Sheet1" || l.name == "Details")));
assert!(!document.entities().any(|e| matches!(e, EntityType::Circle(_))), "the sheet's circle went with its layout");
assert_eq!(host.app.tabs[0].scene.current_layout, "Model", "deleting the current layout fell back to Model");
// U: the report layer and the three deletes; undoing them restores the layouts and the linetype.
drop(process);
drop(host);
app.finish_pending_history(0);
app.undo_steps(4);
let document = &app.tabs[0].scene.document;
assert!(document.line_types.get("Temp").is_some(), "undo restored the linetype");
let names = app.tabs[0].scene.layout_names();
assert!(names.contains(&"Sheet1".to_owned()) && names.contains(&"Details".to_owned()), "undo restored the layouts: {names:?}");
assert!(document.entities().any(|e| matches!(e, EntityType::Circle(_))), "undo restored the sheet's circle");
}
#[test]
fn audit_python_raster_image_definition_linkage_over_real_ipc() {
use acadrust::objects::ObjectType;