forked from 1b-agent/opencadstudio-upstream
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:
1 parent
b1edc9f578
commit
795aa922b4
8 files changed
+960
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(<.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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user