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:
Felix RiestraandClaude Sonnet 5 committed 2026-09-21 16:28:03 +02:00
1 parent aaea15e4ef
commit 8d20d280fe
9 files changed
+612

No files matched your search

+28
View File
@@ -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)
+128
View File
@@ -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})