diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 1f8903987..9b1ff2ee3 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -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 }, + /// Change a simple linetype's description or pattern in place. + LinetypeModify { name: String, description: Option, pattern: Option> }, + /// 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, + scale: Option<[f64; 2]>, + }, } /// The style tables `TableOperation` can rename, delete or make current. diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index aee1a13f2..967181cac 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -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::(&bytes).unwrap(), diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 0ca0d3740..8c870598d 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -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. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 4533d285e..00b94a712 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -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** diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index 8c1894b88..64a7f9761 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -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) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 4e1667330..60c70fb46 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -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 { + use ocs_plugin_api::host::TableOperation; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let pattern = |value: PyObjectRef| -> PyResult> { + let items: Vec = 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::(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::(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 { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let mut uses = std::collections::HashMap::::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::>() + }) + .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 = 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 { + use ocs_plugin_api::host::TableOperation; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let present = |key: &str| -> PyResult> { + 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::(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 { + let degrees = v.try_into_value::(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 { + 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::::new(); + let mut viewports = std::collections::HashMap::::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::>() + }) + .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] diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index 8e9f5ac3d..6149f617b 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -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) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 2ae2d1172..2c6358e67 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -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 { + 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::() <= 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 { + 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 { @@ -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::>().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) { + 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 = 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![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;