feat(python): block contents (create_entity(..., block=))

BlockEntityAdd adds a validated entity to a block definition (owner set by the
host, same validation as a model-space entity, self-nesting inserts refused),
reusing the scene's block-editor routing. Deleting a block member now also
drops its handle from the block record. Real-IPC audit with an attribute
definition inside a block, DWG/DXF persistence and undo; IPC and Python unit
tests; docs.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-21 16:54:30 +02:00
1 parent 8d20d280fe
commit b1edc9f578
8 files changed
+251 -7

No files matched your search

+172 -1
View File
@@ -503,9 +503,18 @@ impl<'a> HostSession<'a> {
}
return removed;
}
let owner = self.document().get_entity(handle).map(|entity| entity.common().owner_handle);
self.app.tabs[self.tab].scene.erase_entities(&[handle]);
let removed = self.document().get_entity(handle).is_none();
if removed {
// The document keeps a block record's member list separately from the
// entity store, and removing an entity leaves its handle listed; drop
// it so a definition never lists a member that no longer exists.
if let Some(owner) = owner {
if let Some(record) = self.document_mut().block_records.iter_mut().find(|record| record.handle == owner) {
record.entity_handles.retain(|member| *member != handle);
}
}
self.publish_document_view();
}
removed
@@ -1248,7 +1257,8 @@ impl<'a> HostSession<'a> {
other @ (TableOperation::BlockCreate { .. }
| TableOperation::BlockModify { .. }
| TableOperation::BlockRename { .. }
| TableOperation::BlockDelete { .. }) => self.block_operation(other),
| TableOperation::BlockDelete { .. }
| TableOperation::BlockEntityAdd { .. }) => self.block_operation(other),
other => self.style_operation(other),
}
}
@@ -1336,6 +1346,42 @@ impl<'a> HostSession<'a> {
self.finish_block_change();
Ok(handle)
}
TableOperation::BlockEntityAdd { block, mut entity } => {
let (name, record_handle) = editable_block(self.document(), block.trim())?;
if matches!(
entity,
E::Viewport(_) | E::Block(_) | E::BlockEnd(_) | E::AttributeEntity(_) | E::Unknown(_) | E::RasterImage(_)
) {
return Err("an entity of this kind cannot be added to a block from a script".to_owned());
}
if self.app.tabs[self.tab].scene.block_edit_block.is_some() {
return Err("finish the open block editor before adding entities to a block".to_owned());
}
if let E::Insert(insert) = &entity {
if insert.block_name.eq_ignore_ascii_case(&name) || block_nests(self.document(), &insert.block_name, &name, 0) {
return Err(format!("inserting {:?} into {name:?} would nest a block inside itself", insert.block_name));
}
}
entity.common_mut().owner_handle = record_handle;
ocs_plugin_api::entity_coverage::validate_new_canvas_entity(&entity)?;
ocs_plugin_api::entity_coverage::bind_canvas_entity_references(self.document(), &mut entity)?;
if self.document().layers.get(&entity.common().layer).is_some_and(|layer| layer.is_locked()) {
return Err(format!("layer {:?} is locked", entity.common().layer));
}
self.push_undo("Add to block");
entity.common_mut().handle = Handle::NULL;
entity.common_mut().owner_handle = record_handle;
// The scene routes an add into the block being edited; reuse that
// path so the entity gets the same preparation as any new entity.
self.app.tabs[self.tab].scene.block_edit_block = Some(record_handle);
let handle = self.add_entity(entity);
self.app.tabs[self.tab].scene.block_edit_block = None;
if handle.is_null() {
return Err(format!("the entity could not be added to block {name:?}"));
}
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() {
@@ -1666,6 +1712,22 @@ impl<'a> HostSession<'a> {
}
}
/// True when block `outer` (transitively) contains an insert of `target`.
fn block_nests(doc: &CadDocument, outer: &str, target: &str, depth: usize) -> bool {
if depth > 64 {
return true;
}
let Some(record) = doc.block_records.get(outer) else {
return false;
};
record.entity_handles.iter().filter_map(|h| doc.get_entity(*h)).any(|entity| match entity {
EntityType::Insert(insert) => {
insert.block_name.eq_ignore_ascii_case(target) || block_nests(doc, &insert.block_name, target, depth + 1)
}
_ => false,
})
}
/// A layer name AutoCAD would accept: 1-255 characters, none of `<>/\":;?*|=\``.
fn validated_layer_name(raw: &str) -> Result<String, String> {
validated_symbol_name(raw, "a layer name")
@@ -7139,6 +7201,115 @@ B.delete('Widget2')
assert_eq!(members("Widget2"), Some(1), "undo restored Widget2");
}
/// Block contents through Python over the real runner: add entities
/// (including an attribute definition) to a definition, edit and delete them
/// with the ordinary entity API, refuse bad requests, persist through
/// DWG/DXF and undo.
#[test]
fn audit_python_block_contents_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_bcontents_{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#"
seed = doc.create_entity('Circle', center=P(0, 0, 0), radius=1)
B.create('Part', [seed], erase_originals=True)
line = doc.create_entity('Line', start=P(0, 0, 0), end=P(5, 0, 0), block='Part')
arc = doc.create_entity('Arc', center=P(0, 0, 0), radius=5.0, start_angle=0.0, end_angle=1.5, block='Part')
tag = doc.create_entity('AttributeDefinition', block='Part', tag='PART_NO', prompt='Part number', default_value='PN-001', insertion_point=P(1, 2, 0), height=2.5)
with doc.transaction('Lengthen'):
line.end = (8, 0, 0)
doc.delete_entity(arc)
B.create('Outer', [doc.create_entity('Circle', center=P(0, 0, 0), radius=1)], erase_originals=True)
doc.create_entity('Insert', block_name='Part', insert_point=P(0, 0, 0), block='Outer')
check('missing_block', lambda: doc.create_entity('Line', start=P(0, 0, 0), end=P(1, 0, 0), block='Nope'))
check('layout_block', lambda: doc.create_entity('Line', start=P(0, 0, 0), end=P(1, 0, 0), block='*Model_Space'))
check('zero_circle', lambda: doc.create_entity('Circle', center=P(1, 1, 0), radius=0, block='Part'))
check('viewport', lambda: doc.create_entity('Viewport', block='Part'))
check('bad_tag', lambda: doc.create_entity('AttributeDefinition', block='Part', tag=' ', prompt='p', default_value='', insertion_point=P(0, 0, 0), height=1))
check('bad_height', lambda: doc.create_entity('AttributeDefinition', block='Part', tag='T', prompt='p', default_value='', insertion_point=P(0, 0, 0), height=0))
check('self_insert', lambda: doc.create_entity('Insert', block_name='Part', insert_point=P(0, 0, 0), block='Part'))
check('cycle', lambda: doc.create_entity('Insert', block_name='Outer', insert_point=P(0, 0, 0), block='Part'))
check('unknown_insert_block', lambda: doc.create_entity('Insert', block_name='Ghost', insert_point=P(0, 0, 0), block='Part'))
"#);
let (refused, accepted) = report(host.document());
assert!(accepted.is_empty(), "accepted invalid requests: {accepted:?}");
assert_eq!(refused, 9);
let check_state = |label: &str, document: &CadDocument| {
let record = document.block_records.get("Part").unwrap_or_else(|| panic!("{label}: Part missing"));
let members: Vec<EntityType> = document.entities()
.filter(|e| e.common().owner_handle == record.handle && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)))
.cloned().collect();
assert_eq!(members.len(), 3, "{label}: circle, line, attribute definition (the arc was deleted): {:?}",
members.iter().map(|e| format!("{e:?}").chars().take(10).collect::<String>()).collect::<Vec<_>>());
let line = members.iter().find_map(|e| match e { EntityType::Line(l) => Some(l.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: line"));
assert_eq!((line.end.x, line.end.y), (8.0, 0.0), "{label}: edited inside the block");
assert!(members.iter().any(|e| matches!(e, EntityType::Circle(_))), "{label}: circle from the seed");
let attdef = members.iter().find_map(|e| match e { EntityType::AttributeDefinition(a) => Some(a.clone()), _ => None }).unwrap_or_else(|| panic!("{label}: attdef"));
assert_eq!((attdef.tag.as_str(), attdef.prompt.as_str(), attdef.default_value.as_str()), ("PART_NO", "Part number", "PN-001"), "{label}");
assert!(!members.iter().any(|e| matches!(e, EntityType::Arc(_))), "{label}: arc deleted");
let outer = document.block_records.get("Outer").unwrap_or_else(|| panic!("{label}: Outer missing"));
let nested = document.entities().any(|e| matches!(e, EntityType::Insert(i) if i.block_name == "Part" && i.common.owner_handle == outer.handle));
assert!(nested, "{label}: Part is inserted inside Outer");
};
check_state("live", host.document());
let dwg = crate::io::load_bytes("bc.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap();
let dxf = crate::io::load_bytes("bc.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap();
check_state("DWG", &dwg);
check_state("DXF", &dxf);
// U: one more add, then the report layer; undoing both leaves the block as before.
run(&mut host, "extra", "doc.create_entity('Circle', center=P(9, 9, 0), radius=2, block='Part')");
assert_eq!(report(host.document()).1.len(), 0);
{
let document = host.document();
let record = document.block_records.get("Part").unwrap();
let live: Vec<_> = record.entity_handles.iter().map(|h| (h.value(), document.get_entity(*h).is_some())).collect();
assert_eq!(record.entity_handles.len(), 4, "record entity_handles (handle, exists): {live:?}");
}
drop(process);
drop(host);
app.finish_pending_history(0);
app.undo_steps(2);
// Membership bookkeeping is intrinsic to an add and stays listed across an entity-delta
// undo (see `Scene::add_entity_internal`), so count the entities that really exist.
let document = &app.tabs[0].scene.document;
let part = document.block_records.get("Part").unwrap();
let existing = part.entity_handles.iter().filter(|h| document.get_entity(**h).is_some()).count();
assert_eq!(existing, 3, "undo removed the added member");
}
#[test]
fn audit_python_raster_image_definition_linkage_over_real_ipc() {
use acadrust::objects::ObjectType;