diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 3dc1ebf6d..cda9ac2a1 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -120,6 +120,28 @@ pub enum TableOperation { StyleDelete { kind: TableStyleKind, name: String }, /// Make a text or dimension style current. StyleSetCurrent { kind: TableStyleKind, name: String }, + /// Define a block from existing drawing entities. The entities are copied + /// into the definition shifted by `-base_point` (the block's origin); + /// `erase_originals` then removes them from the drawing. No insert is + /// placed. Returns the block record's handle. + BlockCreate { + name: String, + entities: Vec, + base_point: [f64; 3], + erase_originals: bool, + description: Option, + }, + /// Change a block's description, explodable or uniform-scale settings. + BlockModify { + name: String, + description: Option, + explodable: Option, + scale_uniformly: Option, + }, + /// Rename a block; every insert of it follows. + BlockRename { from: String, to: String }, + /// Delete an unreferenced block definition and its contents. + BlockDelete { name: String }, } /// 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 1960aa9e0..b8454893d 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -131,6 +131,16 @@ mod tests { TableOperation::StyleRename { kind: crate::host::TableStyleKind::Text, from: "A".into(), to: "B".into() }, TableOperation::StyleDelete { kind: crate::host::TableStyleKind::Dim, name: "A".into() }, TableOperation::StyleSetCurrent { kind: crate::host::TableStyleKind::Dim, name: "A".into() }, + TableOperation::BlockCreate { + name: "B".into(), + entities: vec![acadrust::Handle::new(4), acadrust::Handle::new(5)], + base_point: [1.0, 2.0, 3.0], + erase_originals: true, + description: Some("d".into()), + }, + TableOperation::BlockModify { name: "B".into(), description: None, explodable: Some(false), scale_uniformly: Some(true) }, + TableOperation::BlockRename { from: "B".into(), to: "C".into() }, + TableOperation::BlockDelete { name: "C".into() }, ] { let bytes = bincode::serialize(&PluginRequest::TableOperation { operation: operation.clone() }).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), diff --git a/docs/cadcodec-reader-gaps.md b/docs/cadcodec-reader-gaps.md index 47e35e62d..a23110ff2 100644 --- a/docs/cadcodec-reader-gaps.md +++ b/docs/cadcodec-reader-gaps.md @@ -208,4 +208,6 @@ Found by `audit_python_text_and_dim_styles_over_real_ipc`; each is pinned by a c - **`DIMSTYLE` text style name:** the DXF reader keeps only the text-style handle (group 340) and never resolves `dimtxsty` from it, so the name reopens as `Standard` (the handle is right; DWG resolves both). - **`TextStyle::true_type_font`** is never written or read by either codec, so it lives only in memory. +- **`BLOCK_RECORD` description:** the DXF codec does not carry `BlockRecord::description` + (found by `audit_python_blocks_over_real_ipc`, pinned by a canary); DWG keeps it. diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 1db480afc..f6e1f1d67 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -213,3 +213,17 @@ semantics, rename following references, current/delete guards, five-step undo), tests. Four cadcodec findings (STYLE flags and oblique units, DIMSTYLE name, `true_type_font`) are pinned by canaries and listed in `cadcodec-reader-gaps.md`. Next: linetypes, blocks, layouts; then headless commands. +### 2026-09-21: blocks (`doc.blocks`) + +`TableOperation` gained `BlockCreate` (copy existing model/paper entities into a definition shifted by +`-base_point`, optionally erasing the originals), `BlockModify` (description, explodable, uniform scale), +`BlockRename` (inserts follow, via `Scene::rename_block`) and `BlockDelete` (refused while an insert, a +dimension-style arrow or a multileader uses it; removes the members, markers, record and orphaned draw-order +tables). Creation reuses `Scene::define_block_from_owned_entities`. Python: `blocks.create/modify/rename/delete`, +lookup and iteration, records with member handles and kinds. Evidence: `audit_python_blocks_over_real_ipc` +(18 refusals; shifted geometry and base point; inserts following a rename; original kept vs erased; +live/DWG/DXF persistence; guarded delete; three-step undo restoring the definitions), IPC round-trip and Python +unit tests. One more cadcodec finding (DXF drops the block description) is pinned by a canary. Not covered: +editing a definition's contents in place, and attribute definitions inside a scripted block. Next: linetypes and +layouts, then headless commands. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index 49a2f4b75..63190db8a 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -185,6 +185,34 @@ Known persistence limits (cadcodec, see `docs/cadcodec-reader-gaps.md`): a DXF save drops the backward/upside-down flags of a text style and does not restore a dimension style's `dimtxsty` name (the handle link survives); DWG keeps both. +### Blocks: `ocs.active_document.blocks` + +```python +doc = ocs.active_document +line = doc.create_entity("Line", start=..., end=...) +circle = doc.create_entity("Circle", center=..., radius=1) +B = doc.blocks +B.create("Widget", [line, circle], base_point=(1, 1, 0), description="a widget") +doc.create_entity("Insert", block_name="Widget", insert_point={"x": 10, "y": 0, "z": 0}) +B.modify("Widget", explodable=False) +B.rename("Widget", "Gadget") # every insert follows +B["Gadget"]["entities"], B["Gadget"]["insert_count"], B.names() +B.delete("Gadget") # refused while an insert, style or leader uses it +``` + +`create` copies model or paper space entities (descriptors or handles) into a new +definition shifted by `-base_point`, so an insert at `p` puts the base point at +`p`. `erase_originals=True` removes the sources, as the BLOCK command does; the +default keeps them. No insert is placed. Refused: an empty, duplicate, invalid or +`*`-prefixed name, no entities, a missing or repeated entity, an entity inside a +block, a viewport, a non-finite base point, renaming or deleting layout, +anonymous or externally referenced blocks, and a case-only rename. Deleting also +removes the definition's contents. Records list user blocks only (`handle`, +`name`, `description`, `explodable`, `scale_uniformly`, `base_point`, +`insert_count`, `entities` as `{handle, kind}`). Editing a definition's contents +in place and creating attribute definitions inside a block from a script are not +available yet. A DXF save drops the block description (cadcodec); DWG keeps it. + ### 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 61dfa2fa9..b414bf8cf 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -396,12 +396,77 @@ class _DimStyles(_Styles): return self[name] +class _Blocks: + """`ocs.active_document.blocks`: user block definitions (layout and + anonymous blocks are not listed). Every change is validated by the host, is + one undo step, and a refusal raises `RuntimeError` without changing the + drawing. Place a block with `create_entity("Insert", block_name=...)`.""" + + def _records(self): + return ocs.block_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()] + + def create(self, name, entities, base_point=(0, 0, 0), erase_originals=False, description=None): + """Define a block from existing model/paper-space entities (descriptors + or handles). They are copied into the definition shifted by + `-base_point`; `erase_originals=True` removes them from the drawing, as + the BLOCK command does. No insert is placed.""" + handles = [e.handle if isinstance(e, _Entity) else int(e) for e in entities] + options = {"entities": handles, "base_point": [float(v) for v in base_point], + "erase_originals": bool(erase_originals)} + if description is not None: + options["description"] = str(description) + ocs.block_operation("create", str(name), options) + return self[name] + + def modify(self, name, description=None, explodable=None, scale_uniformly=None): + options = {k: v for k, v in (("description", description), ("explodable", explodable), + ("scale_uniformly", scale_uniformly)) if v is not None} + ocs.block_operation("modify", str(name), options) + return self[name] + + def rename(self, name, new_name): + """Rename a block; every insert of it follows.""" + ocs.block_operation("rename", str(name), {"to": str(new_name)}) + return self[new_name] + + def delete(self, name): + """Delete a block that no insert, style or leader still uses.""" + ocs.block_operation("delete", str(name), None) + + class _Document: def __init__(self): self._pending = None self.layers = _Layers() self.text_styles = _TextStyles() self.dim_styles = _DimStyles() + self.blocks = _Blocks() 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 42c5804d3..711456616 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -1270,6 +1270,134 @@ mod ocs { Ok(vm.ctx.new_list(output).into()) } + /// Block-definition change through the host. `op` is `create` + /// (`entities`, `base_point`, `erase_originals`, `description`), `modify` + /// (`description`, `explodable`, `scale_uniformly`), `rename` (`to`) or + /// `delete`. Returns the block record's handle. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn block_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" => { + ensure_known_entity_keys(&options, "block", &["entities", "base_point", "erase_originals", "description"], vm)?; + let handles = present("entities")? + .ok_or_else(|| vm.new_value_error("ocs: a block needs entities".to_owned()))? + .try_into_value::>(vm)?; + let base_point = match present("base_point")? { + Some(value) => py_to_f64_array::<3>(value, vm)?, + None => [0.0; 3], + }; + TableOperation::BlockCreate { + name, + entities: handles.into_iter().map(Handle::new).collect(), + base_point, + erase_originals: get_opt_bool(&options, "erase_originals", vm)?, + description: present("description")?.map(|v| v.try_into_value::(vm)).transpose()?, + } + } + "modify" => { + ensure_known_entity_keys(&options, "block", &["description", "explodable", "scale_uniformly"], vm)?; + TableOperation::BlockModify { + name, + description: present("description")?.map(|v| v.try_into_value::(vm)).transpose()?, + explodable: present("explodable")?.map(|v| v.try_into_value::(vm)).transpose()?, + scale_uniformly: present("scale_uniformly")?.map(|v| v.try_into_value::(vm)).transpose()?, + } + } + "rename" => { + ensure_known_entity_keys(&options, "block 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::BlockRename { from: name, to } + } + "delete" => { + ensure_known_entity_keys(&options, "block delete", &[], vm)?; + TableOperation::BlockDelete { name } + } + other => return Err(vm.new_value_error(format!("ocs.block_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.block_operation: {error}"))) + } + + /// Every user block definition (layouts and anonymous blocks are omitted). + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn block_records(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let mut inserts = std::collections::HashMap::::new(); + for entity in document.entities() { + if let acadrust::entities::EntityType::Insert(insert) = entity { + *inserts.entry(insert.block_name.to_uppercase()).or_default() += 1; + } + } + document + .block_records + .iter() + .filter(|record| !record.is_layout() && !record.is_anonymous() && !record.flags.is_xref) + .map(|record| { + let members: Vec<(u64, String)> = record + .entity_handles + .iter() + .filter_map(|handle| document.get_entity(*handle)) + .map(|entity| { + let kind = ocs_plugin_api::entity_coverage::entity_snapshot(entity) + .ok() + .and_then(|value| value.as_object().and_then(|o| o.keys().next().cloned())) + .unwrap_or_else(|| "Unknown".to_owned()); + (entity.common().handle.value(), kind) + }) + .collect(); + ( + record.clone(), + members, + inserts.get(&record.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 (record, members, insert_count) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(record.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(record.name.clone()), vm)?; + dict.set_item("description", vm.new_pyobj(record.description.clone()), vm)?; + dict.set_item("explodable", vm.new_pyobj(record.explodable), vm)?; + dict.set_item("scale_uniformly", vm.new_pyobj(record.scale_uniformly), vm)?; + let base = [record.base_point.x, record.base_point.y, record.base_point.z]; + dict.set_item("base_point", PyObjectRef::from(vm.ctx.new_list(base.iter().map(|v| vm.new_pyobj(*v)).collect())), vm)?; + dict.set_item("insert_count", vm.new_pyobj(insert_count), vm)?; + let entities = members + .into_iter() + .map(|(handle, kind)| { + let member = vm.ctx.new_dict(); + member.set_item("handle", vm.new_pyobj(handle), vm)?; + member.set_item("kind", vm.new_pyobj(kind), vm)?; + Ok(PyObjectRef::from(member)) + }) + .collect::>>()?; + dict.set_item("entities", PyObjectRef::from(vm.ctx.new_list(entities)), 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 cdd7dd999..8823092c4 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -39,6 +39,8 @@ class DocumentModelTests(unittest.TestCase): text_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Title", "Notes")], dim_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Metric")], style_operation=lambda kind, op, name, options: self.calls.append((kind, op, name, options)) or 9, + 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, ) namespace = {"ocs": self.ocs} exec(MODEL.read_text(), namespace) @@ -94,6 +96,22 @@ class DocumentModelTests(unittest.TestCase): dim.set_current("Standard") self.assertEqual(self.calls[-1], ("dim", "set_current", "Standard", None)) + def test_blocks(self): + blocks = self.doc.blocks + self.assertIn("WIDGET", blocks) + self.assertEqual(blocks.names(), ["Widget", "Gadget"]) + blocks.create("Gadget", [self.doc.entities[1], 2], base_point=(1, 2, 3), erase_originals=True) + self.assertEqual(self.calls[-1], ("block", "create", "Gadget", + {"entities": [1, 2], "base_point": [1.0, 2.0, 3.0], "erase_originals": True})) + blocks.modify("Widget", explodable=False) + self.assertEqual(self.calls[-1], ("block", "modify", "Widget", {"explodable": False})) + blocks.rename("Widget", "Gadget") + self.assertEqual(self.calls[-1], ("block", "rename", "Widget", {"to": "Gadget"})) + blocks.delete("Widget") + self.assertEqual(self.calls[-1], ("block", "delete", "Widget", None)) + with self.assertRaises(KeyError): + blocks["Nope"] + def test_create_and_delete_use_document_model(self): line = self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, end={"x": 10, "y": 0, "z": 0}) diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 163d6d43d..ba60109b5 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -1245,10 +1245,197 @@ impl<'a> HostSession<'a> { self.app.set_current_layer_name(self.tab, &stored)?; Ok(handle) } + other @ (TableOperation::BlockCreate { .. } + | TableOperation::BlockModify { .. } + | TableOperation::BlockRename { .. } + | TableOperation::BlockDelete { .. }) => self.block_operation(other), other => self.style_operation(other), } } + /// 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 { + use acadrust::entities::EntityType as E; + use ocs_plugin_api::host::TableOperation; + let editable_block = |doc: &CadDocument, name: &str| -> Result<(String, Handle), String> { + let record = doc + .block_records + .get(name) + .ok_or_else(|| format!("block {name:?} does not exist"))?; + if record.is_layout() || record.is_anonymous() || record.flags.is_xref || record.flags.is_xref_overlay || record.name.contains('|') { + return Err(format!("block {:?} is a layout, anonymous or externally referenced block", record.name)); + } + Ok((record.name.clone(), record.handle)) + }; + match operation { + TableOperation::BlockCreate { name, entities, base_point, erase_originals, description } => { + let name = validated_symbol_name(&name, "a block name")?; + if name.starts_with('*') { + return Err("a block name cannot start with '*'".to_owned()); + } + if self.document().block_records.get(&name).is_some() { + return Err(format!("block {name:?} already exists")); + } + if base_point.iter().any(|v| !v.is_finite()) { + return Err("the block base point must be finite".to_owned()); + } + if entities.is_empty() { + return Err("a block needs at least one entity".to_owned()); + } + let mut seen = std::collections::HashSet::new(); + let mut sources = Vec::with_capacity(entities.len()); + for handle in &entities { + if !seen.insert(handle.value()) { + return Err(format!("entity {} is listed twice", handle.value())); + } + let entity = self + .document() + .get_entity(*handle) + .ok_or_else(|| format!("entity {} does not exist", handle.value()))?; + let owner = entity.common().owner_handle; + let in_canvas = self + .document() + .block_records + .iter() + .any(|record| record.handle == owner && record.is_layout()); + if !in_canvas { + return Err(format!("entity {} is not in model or paper space", handle.value())); + } + if matches!(entity, E::Viewport(_) | E::Block(_) | E::BlockEnd(_) | E::AttributeEntity(_) | E::Unknown(_)) { + return Err(format!("entity {} of this kind cannot be placed in a block", handle.value())); + } + sources.push(entity.clone()); + } + if let Some(text) = &description { + if text.chars().any(|c| c.is_control() && c != '\n') { + return Err("a block description cannot contain control characters".to_owned()); + } + } + self.push_undo("Create block"); + let base = glam::DVec3::new(base_point[0], base_point[1], base_point[2]); + self.app.tabs[self.tab] + .scene + .define_block_from_owned_entities(sources, &name, base)?; + let (stored, handle) = editable_block(self.document(), &name)?; + if let Some(record) = self.document_mut().block_records.get_mut(&stored) { + record.base_point = acadrust::types::Vector3::new(base_point[0], base_point[1], base_point[2]); + if let Some(text) = description { + record.description = text; + } + } + let marker = self.document().block_records.get(&stored).map(|r| r.block_entity_handle); + if let Some(marker) = marker { + if let Some(E::Block(block)) = self.document_mut().get_entity_mut(marker) { + block.base_point = acadrust::types::Vector3::new(base_point[0], base_point[1], base_point[2]); + } + } + if erase_originals { + self.app.tabs[self.tab].scene.erase_entities(&entities); + } + self.finish_block_change(); + Ok(handle) + } + TableOperation::BlockModify { name, description, explodable, scale_uniformly } => { + let (name, handle) = editable_block(self.document(), name.trim())?; + if description.is_none() && explodable.is_none() && scale_uniformly.is_none() { + return Err("no block properties to change".to_owned()); + } + if let Some(text) = &description { + if text.chars().any(|c| c.is_control() && c != '\n') { + return Err("a block description cannot contain control characters".to_owned()); + } + } + self.push_undo("Modify block"); + if let Some(record) = self.document_mut().block_records.get_mut(&name) { + if let Some(text) = description { + record.description = text; + } + if let Some(value) = explodable { + record.explodable = value; + } + if let Some(value) = scale_uniformly { + record.scale_uniformly = value; + } + } + self.finish_block_change(); + Ok(handle) + } + TableOperation::BlockRename { from, to } => { + let (from, handle) = editable_block(self.document(), from.trim())?; + let to = validated_symbol_name(&to, "a block name")?; + if to.starts_with('*') { + return Err("a block name cannot start with '*'".to_owned()); + } + if from.eq_ignore_ascii_case(&to) { + return Err("the new block name matches the current one (a case-only change is not supported)".to_owned()); + } + if self.document().block_records.get(&to).is_some() { + return Err(format!("block {to:?} already exists")); + } + self.push_undo("Rename block"); + if !self.app.tabs[self.tab].scene.rename_block(&from, &to) { + return Err("the block could not be renamed".to_owned()); + } + self.finish_block_change(); + Ok(handle) + } + TableOperation::BlockDelete { name } => { + let (name, handle) = editable_block(self.document(), name.trim())?; + let doc = self.document(); + let referenced = doc.entities().any(|entity| match entity { + E::Insert(insert) => insert.block_name.eq_ignore_ascii_case(&name), + E::MultiLeader(leader) => { + leader.block_content_handle == Some(handle) + || leader.context.block_content_handle == Some(handle) + } + _ => false, + }) || doc.dim_styles.iter().any(|style| { + [style.dimblk, style.dimblk1, style.dimblk2, style.dimldrblk].contains(&handle) + }); + if referenced { + return Err(format!("block {name:?} is still inserted or used by a style or leader")); + } + let members: Vec = { + let record = doc.block_records.get(&name).expect("checked above"); + let mut members = record.entity_handles.clone(); + members.push(record.block_entity_handle); + members.push(record.block_end_handle); + members + }; + self.push_undo("Delete block"); + let doc = self.document_mut(); + for member in members { + doc.remove_entity(member); + } + doc.block_records.remove(&name); + let orphans: Vec = { + let live: std::collections::HashSet = doc.block_records.iter().map(|r| r.handle).collect(); + doc.objects + .iter() + .filter_map(|(h, object)| match object { + acadrust::objects::ObjectType::SortEntitiesTable(table) if !live.contains(&table.block_owner_handle) => Some(*h), + _ => None, + }) + .collect() + }; + for orphan in orphans { + doc.objects.remove(&orphan); + } + self.finish_block_change(); + Ok(handle) + } + _ => Err("unsupported table operation".to_owned()), + } + } + + fn finish_block_change(&mut self) { + self.app.tabs[self.tab].dirty = true; + self.app.tabs[self.tab].scene.invalidate_dependency_index(); + self.app.tabs[self.tab].scene.bump_geometry(); + self.publish_document_view(); + } + /// Text and dimension style operations. They act on the active document /// because the shared style machinery (in-use scan, rename, ribbon sync) is /// keyed by the active tab. @@ -6814,6 +7001,144 @@ S.delete('Heading') assert!(document.dim_styles.get("Metric").is_some() && document.dim_styles.get("Metric2").is_some(), "undo restored the dimension styles"); } + /// Block definitions through Python over the real runner: create from + /// entities (with and without erasing them), modify, rename with inserts + /// following, guarded delete, DWG/DXF persistence and undo. + #[test] + fn audit_python_blocks_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_blocks_{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\nB = doc.blocks\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#" +line = doc.create_entity('Line', start=P(0, 0, 0), end=P(4, 0, 0)) +circle = doc.create_entity('Circle', center=P(2, 2, 0), radius=1) +B.create('Widget', [line, circle], base_point=(1, 1, 0), description='a widget') +B.create('Widget2', [line.handle], erase_originals=True) +doc.create_entity('Insert', block_name='Widget', insert_point=P(10, 0, 0)) +doc.create_entity('Insert', block_name='widget', insert_point=P(20, 0, 0)) +B.modify('Widget', explodable=False, description='a gadget') +B.rename('Widget', 'Gadget') +member = B['Gadget']['entities'][0]['handle'] +check('dup', lambda: B.create('gadget', [circle])) +check('empty', lambda: B.create('E1', [])) +check('badname', lambda: B.create('a Vec { + let record = document.block_records.get(name).unwrap_or_else(|| panic!("{label}: block {name} missing")); + document.entities().filter(|e| e.common().owner_handle == record.handle + && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))).cloned().collect() + }; + let gadget = members("Gadget"); + assert_eq!(gadget.len(), 2, "{label}: Gadget members"); + let line = gadget.iter().find_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: line")); + assert_eq!((line.start.x, line.start.y, line.end.x, line.end.y), (-1.0, -1.0, 3.0, -1.0), "{label}: shifted by -base"); + let circle = gadget.iter().find_map(|e| match e { EntityType::Circle(c) => Some(c.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: circle")); + assert_eq!((circle.center.x, circle.center.y, circle.radius), (1.0, 1.0, 1.0), "{label}"); + let record = document.block_records.get("Gadget").unwrap(); + assert_eq!((record.base_point.x, record.base_point.y), (1.0, 1.0), "{label}: base point"); + assert!(!record.explodable, "{label}: explodable"); + if label == "DXF" { + // BLOCKER (cadcodec): the DXF BLOCK_RECORD reader/writer does not carry the + // description, so it is lost on a DXF save (DWG keeps it). + assert_eq!(record.description, "", "{label}: cadcodec now keeps the description; flip this canary"); + } else { + assert_eq!(record.description, "a gadget", "{label}: description"); + } + assert_eq!(members("Widget2").len(), 1, "{label}: Widget2 members"); + let inserts: Vec<_> = document.entities().filter_map(|e| match e { EntityType::Insert(i) => Some(i.block_name.clone()), _ => None }).collect(); + assert_eq!(inserts.iter().filter(|n| n.as_str() == "Gadget").count(), 2, "{label}: inserts follow the rename: {inserts:?}"); + // originals: Widget kept the circle and line in model space, Widget2 erased its line copy's source + let canvas_kinds: Vec<_> = document.entities().filter(|e| { + document.block_records.iter().any(|r| r.handle == e.common().owner_handle && r.is_layout()) + }).map(|e| format!("{:?}", e).chars().take(6).collect::()).collect(); + assert_eq!(canvas_kinds.iter().filter(|k| k.starts_with("Circle")).count(), 1, "{label}: circle original kept: {canvas_kinds:?}"); + assert_eq!(canvas_kinds.iter().filter(|k| k.starts_with("Line(")).count(), 0, "{label}: line original erased: {canvas_kinds:?}"); + }; + check_state("live", host.document()); + assert_eq!(host.document().block_records.get("Gadget").unwrap().description, "a gadget"); + let dwg = crate::io::load_bytes("blocks.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("blocks.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + run(&mut host, "destroy", r#" +for entity in list(doc.entities): + if entity.kind == 'Insert': + doc.delete_entity(entity) +B.delete('Gadget') +B.delete('Widget2') +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "{accepted:?}"); + assert_eq!(refused, 0); + let document = host.document(); + assert!(document.block_records.get("Gadget").is_none() && document.block_records.get("Widget2").is_none()); + assert!(!document.entities().any(|e| matches!(e, EntityType::Line(_))), "block members were removed with their definitions"); + assert_eq!(document.entities().filter(|e| matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_))).count(), 0); + + // U: the report layer and both block deletes; undoing them restores the definitions. + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(3); + let document = &app.tabs[0].scene.document; + let members = |name: &str| document.block_records.get(name).map(|r| r.entity_handles.len()); + assert_eq!(members("Gadget"), Some(2), "undo restored Gadget with its members"); + assert_eq!(members("Widget2"), Some(1), "undo restored Widget2"); + } + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType;