forked from 1b-agent/opencadstudio-upstream
feat(python): block definitions (doc.blocks)
TableOperation gains BlockCreate (copy entities into a definition shifted by the base point, optionally erasing originals), BlockModify, BlockRename (inserts follow) and BlockDelete (refused while referenced; removes members, markers and orphaned draw-order tables). Real-IPC audit with DWG/DXF persistence and undo, IPC and Python unit tests, docs; the DXF loss of the block description is pinned by a canary. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
aaea15e4ef
commit
8d20d280fe
9 files changed
+612
No files matched your search
@@ -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<Handle>,
|
||||
base_point: [f64; 3],
|
||||
erase_originals: bool,
|
||||
description: Option<String>,
|
||||
},
|
||||
/// Change a block's description, explodable or uniform-scale settings.
|
||||
BlockModify {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
explodable: Option<bool>,
|
||||
scale_uniformly: Option<bool>,
|
||||
},
|
||||
/// 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.
|
||||
|
||||
@@ -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::<PluginRequest>(&bytes).unwrap(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<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" => {
|
||||
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::<Vec<u64>>(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::<String>(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::<String>(vm)).transpose()?,
|
||||
explodable: present("explodable")?.map(|v| v.try_into_value::<bool>(vm)).transpose()?,
|
||||
scale_uniformly: present("scale_uniformly")?.map(|v| v.try_into_value::<bool>(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::<String>(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<PyObjectRef> {
|
||||
let rows = host_ctx::with_host(|host| {
|
||||
let document = host.document();
|
||||
let mut inserts = std::collections::HashMap::<String, usize>::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::<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 (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::<PyResult<Vec<_>>>()?;
|
||||
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]
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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<Handle, String> {
|
||||
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<Handle> = {
|
||||
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<Handle> = {
|
||||
let live: std::collections::HashSet<Handle> = 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::<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#"
|
||||
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<b', [circle]))
|
||||
check('star', lambda: B.create('*U9', [circle]))
|
||||
check('missing_entity', lambda: B.create('E2', [999999]))
|
||||
check('twice', lambda: B.create('E3', [circle, circle]))
|
||||
check('nonfinite_base', lambda: B.create('E4', [circle], base_point=(1e999, 0, 0)))
|
||||
check('in_block', lambda: B.create('E5', [member]))
|
||||
check('unknown_key', lambda: ocs.block_operation('create', 'E6', {'entities': [circle.handle], 'origin': [0, 0, 0]}))
|
||||
check('rename_missing', lambda: B.rename('Nope', 'X'))
|
||||
check('rename_clash', lambda: B.rename('Gadget', 'WIDGET2'))
|
||||
check('rename_case', lambda: B.rename('Gadget', 'GADGET'))
|
||||
check('rename_star', lambda: B.rename('Gadget', '*Y'))
|
||||
check('delete_missing', lambda: B.delete('Nope'))
|
||||
check('delete_inserted', lambda: B.delete('Gadget'))
|
||||
check('delete_layout', lambda: B.delete('*Model_Space'))
|
||||
check('modify_none', lambda: B.modify('Gadget'))
|
||||
check('modify_missing', lambda: B.modify('Nope', description='x'))
|
||||
"#);
|
||||
let (refused, accepted) = report(host.document());
|
||||
assert!(accepted.is_empty(), "accepted invalid requests: {accepted:?}");
|
||||
assert_eq!(refused, 18);
|
||||
|
||||
let check_state = |label: &str, document: &CadDocument| {
|
||||
assert!(document.block_records.get("Widget").is_none(), "{label}: old block name gone");
|
||||
let members = |name: &str| -> Vec<EntityType> {
|
||||
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::<String>()).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;
|
||||
|
||||
Reference in new issue
Block a user