diff --git a/docs/automation/API-SPEC.md b/docs/automation/API-SPEC.md index eb8a18091..80621d059 100644 --- a/docs/automation/API-SPEC.md +++ b/docs/automation/API-SPEC.md @@ -316,10 +316,10 @@ registry with write rules), `properties`, `measure` (length/area/bounds), | Op | Fields | Semantics | |---|---|---| | `block_define` | `name`, `base:[x,y,z]`, `handles`, optional `insert_at`, optional `"replace":true` | The sources move into the new definition (flattened at `base` = block origin) and one `Insert` is placed at `insert_at` (default `base`), so the drawing looks unchanged. `"replace":true` drops a same-named definition — children, markers and inserts — inside the same undo step before creating the new one (idempotent re-import). Result `{"block":name,"insert":""}`. Names are unique, `*` is reserved. | -| `block_delete` | `name` | Removes the definition completely: child entities, block markers, every `Insert` referencing it and the table record. One undo step. Result `erased`. Unknown → `block_missing`; `*`-records → `block_protected`. | +| `block_delete` | `name`, optional `"force":true` | Removes the definition completely: child entities, block markers and the table record. One undo step (`Delete block `). A referenced block is refused (`block_referenced`); `"force":true` erases the referencing INSERTs in the same step and reports `result.references`. Result `erased`. Unknown → `block_missing`; layout/anonymous/xref/dynamic records → `block_protected`; open BEDIT session → `block_in_edit`; invalid/taken names → `block_invalid_name` / `block_exists`. | | `wblock` | `path`, `handles` *or* `block`; optional `"template":""` (the export inherits that file's tables/styles), optional `"normalize":true` (the export is shifted so its overall bounds minimum lands on the origin) | Writes a standalone DWG/DXF (extension decides). The source document is untouched. Result `{path, entities, normalized}`. | -REST: `POST /blocks`, `DELETE /blocks/{name}`, `POST /wblock`. +REST: `POST /blocks`, `DELETE /blocks/{name}` (add `?force=true` to erase the referencing INSERTs instead of being refused), `POST /wblock`. ### 6.6 Layouts and plotting diff --git a/docs/automation/README.md b/docs/automation/README.md index f86d921a5..c450e25cc 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -701,9 +701,15 @@ definition — its children, markers and inserts — inside the same undo step before creating the new one: that is SPM's barcode re-import flow, so `block_define` never fails with "already exists" on a re-import. `block_delete` removes a definition completely (children, Block/BlockEnd -markers, every Insert referencing it and the table record) and reports -`result.erased`; unknown names are `block_missing`, `*`-prefixed layout -records are `block_protected`. Definitions survive save/open round trips +markers and the table record). A definition anything still points at — an +INSERT, a dimension-style arrow block, a multi-leader's block content — is +refused with `block_referenced` and the shared message text; add +`"force":true` to erase the referencing INSERTs in the same undo step and get +`result.references` with the number that were overridden. `result.erased` +counts every entity the step removed. Unknown names are `block_missing`, +`*`-prefixed layout records, anonymous and xref definitions are +`block_protected`, and a block with an open BEDIT session is `block_in_edit`. +Definitions survive save/open round trips and are consumable by `wblock` (by block name) and `INSERT` creation via `entities_create`. diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index a9c37af67..9b102e463 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -358,62 +358,6 @@ impl OpenCADStudio { .map(|s| s.to_uppercase()) .collect(); - // Live blocks are reachable from layouts and style roots - // through the shared graph. Dangling layouts do not count. - let live_blocks: rustc_hash::FxHashSet = { - let doc = &self.tabs[i].scene.document; - let is_real_layout = |br: &codec::BlockRecord| -> bool { - let up = br.name.to_ascii_uppercase(); - up.starts_with("*MODEL_SPACE") - || up.starts_with("*PAPER_SPACE") - || matches!( - doc.objects.get(&br.layout), - Some(codec::objects::ObjectType::Layout(_)) - ) - }; - let children = |name: &str| -> Vec { - let mut out = Vec::new(); - let Some(br) = doc.block_records.get(name) else { - return out; - }; - for &h in &br.entity_handles { - let Some(entity) = doc.get_entity(h) else { - continue; - }; - out.extend( - crate::scene::render_graph::entity_block_uses(doc, entity, 1.0) - .into_iter() - .filter(|block_use| !block_use.block.is_null()) - .map(|block_use| block_use.insert.block_name), - ); - } - out - }; - let mut live = rustc_hash::FxHashSet::default(); - let mut stack: Vec = doc - .block_records - .iter() - .filter(|&br| is_real_layout(br)) - .map(|br| br.name.clone()) - .collect(); - stack.extend( - crate::scene::render_graph::document_block_uses(doc) - .into_iter() - .map(|block_use| block_use.insert.block_name), - ); - while let Some(n) = stack.pop() { - if !live.insert(n.clone()) { - continue; - } - for c in children(&n) { - if !live.contains(&c) { - stack.push(c); - } - } - } - live - }; - // Build removal lists (still immutable) let layer_remove: Vec = if do_layers { self.tabs[i] @@ -466,26 +410,21 @@ impl OpenCADStudio { vec![] }; - // Remove unreachable blocks but retain live xrefs and space containers. + // Remove blocks nothing references any more. The shared + // service decides what qualifies — layout/xref/dynamic + // records and open BEDIT sessions are protected, and so is + // every reference kind — so a purge and a delete give the + // same answer. let block_remove: Vec = if do_blocks { - self.tabs[i] - .scene - .document - .block_records + let open_edits: Vec = self.tabs[i] + .block_edits .iter() - .filter(|br| { - let up = br.name.to_ascii_uppercase(); - // Never PURGE an xref *definition* — that is a - // detach, not a purge. Orphaned dependencies from - // a past detach still use the reachability test. - !br.flags.is_xref - && !br.flags.is_xref_overlay - && !up.starts_with("*MODEL_SPACE") - && !up.starts_with("*PAPER_SPACE") - && !live_blocks.contains(&br.name) - }) - .map(|br| br.name.clone()) - .collect() + .map(|session| session.br_handle) + .collect(); + crate::scene::block_service::purge_candidates( + &self.tabs[i].scene.document, + &open_edits, + ) } else { vec![] }; @@ -501,7 +440,7 @@ impl OpenCADStudio { // Snapshot the pre-purge document once, before the first // removal, so a single UNDO restores everything. if !snapshot_pushed { - self.push_undo_snapshot(i, "PURGE"); + self.push_undo_snapshot(i, "Purge"); snapshot_pushed = true; } @@ -523,26 +462,11 @@ impl OpenCADStudio { .count(); let mut blocks_gone = 0usize; for name in &block_remove { - // Drop the block definition's member entities (and the - // BLOCK/ENDBLK delimiters) before the record so no orphaned - // geometry survives in the document entity list. - let handles: Vec<_> = - if let Some(br) = self.tabs[i].scene.document.block_records.get(name) { - let mut h = br.entity_handles.clone(); - h.push(br.block_entity_handle); - h.push(br.block_end_handle); - h - } else { - Vec::new() - }; - for h in handles { - self.tabs[i].scene.document.remove_entity(h); - } - if self.tabs[i] - .scene - .document - .block_records - .remove(name) + // The service erases the definition, its member + // entities, both BLOCK/ENDBLK delimiters and any stale + // draw-order table, so a purge and a delete leave the + // same document behind. + if crate::scene::block_service::erase(&mut self.tabs[i].scene, name) .is_some() { blocks_gone += 1; @@ -586,7 +510,7 @@ impl OpenCADStudio { // re-assignment needed (a block purge already snapshotted; // a snapshot here only matters when nothing else changed). if !snapshot_pushed { - self.push_undo_snapshot(i, "PURGE"); + self.push_undo_snapshot(i, "Purge"); } for h in &orphans { self.tabs[i].scene.document.objects.remove(h); @@ -3392,7 +3316,13 @@ impl OpenCADStudio { } else { // Snapshot BEFORE mutating (undo must restore the old // name); dropped again if nothing was renamed. - self.push_undo_snapshot(i, "RENAME"); + let block = type_str == "BLOCK"; + let label = if block { + format!("Rename block {old_name} to {new_name}") + } else { + "RENAME".to_string() + }; + self.push_undo_snapshot(i, label); let known = matches!( type_str.as_str(), "LAYER" @@ -3405,11 +3335,31 @@ impl OpenCADStudio { | "UCS" | "VIEW" ); - let ok = if type_str == "BLOCK" { + // The block path's refusal text comes from the shared + // service, so the command line, the palette, the plugin + // API and the control op all word it the same way. + let mut block_error: Option = None; + let ok = if block { + let open_edits: Vec = self.tabs[i] + .block_edits + .iter() + .map(|session| session.br_handle) + .collect(); // Re-keys the record, syncs the Block marker and every - // INSERT reference; refuses anonymous/xref sources and - // invalid/taken names. - self.tabs[i].scene.rename_block(&old_name, &new_name) + // INSERT reference; refuses anonymous/xref sources, + // protected records and invalid/taken names. + match crate::scene::block_service::rename( + &mut self.tabs[i].scene, + &old_name, + &new_name, + &open_edits, + ) { + Ok(_) => true, + Err(error) => { + block_error = Some(error.to_string()); + false + } + } } else if type_str == "LAYER" { self.tabs[i].rename_layer(&old_name, &new_name) } else if known { @@ -3444,13 +3394,10 @@ impl OpenCADStudio { ); } else { self.discard_last_undo_entry(i); - if !known { + if let Some(message) = block_error { + self.command_line.push_error(&message); + } else if !known { self.command_line.push_error(crate::tf!("RENAME: unknown type '{}'. Use LAYER BLOCK STYLE DIMSTYLE LINETYPE UCS VIEW", type_str).as_ref()); - } else if type_str == "BLOCK" { - self.command_line.push_error(crate::tf!( - "RENAME: could not rename block '{}' — missing/anonymous/xref, or '{}' is invalid or taken.", - old_name, new_name - ).as_ref()); } else { self.command_line.push_error(crate::tf!( "RENAME: could not rename '{}' in {} — not found or protected, or '{}' is invalid or taken.", diff --git a/src/app/control/entities.rs b/src/app/control/entities.rs index 514b7c60f..e26214e1b 100644 --- a/src/app/control/entities.rs +++ b/src/app/control/entities.rs @@ -899,33 +899,37 @@ impl OpenCADStudio { Ok(Task::none()) } - /// `block_delete` — remove a block definition together with its child - /// entities, the Block/BlockEnd markers and every Insert referencing it. + /// `block_delete` — remove a block definition. A definition something + /// still points at is refused unless the request carries `"force": true`, + /// which erases the referencing INSERTs in the same undo step. pub(super) fn control_block_delete(&mut self, req: &Value) -> Result, Value> { let name = string(req, "name")?.to_owned(); - if name.starts_with('*') { - return Err(failure( - "block_protected", - "Layout block records cannot be deleted", - )); - } + let force = req["force"].as_bool().unwrap_or(false); let i = self.active_tab; - if self.tabs[i] - .scene - .document - .block_records - .get(&name) - .is_none() - { - return Err(failure( - "block_missing", - format!("Block '{name}' does not exist"), - )); - } - self.push_undo_snapshot(i, "BLOCKDELETE"); - let erased = erase_block_definition(&mut self.tabs[i].scene, &name); + let open_edits: Vec = self.tabs[i] + .block_edits + .iter() + .map(|session| session.br_handle) + .collect(); + self.push_undo_snapshot(i, format!("Delete block {name}")); + let options = crate::scene::block_service::DeleteOptions { + force, + open_edits: &open_edits, + }; + let outcome = + match crate::scene::block_service::delete(&mut self.tabs[i].scene, &name, options) { + Ok(outcome) => outcome, + Err(error) => { + self.discard_last_undo_entry(i); + return Err(failure(block_error_code(&error), error.to_string())); + } + }; self.post_ref_op(i); - self.set_control_result(json!({ "block": name, "erased": erased })); + self.set_control_result(json!({ + "block": outcome.name, + "erased": outcome.erased, + "references": outcome.references.len(), + })); Ok(Task::none()) } @@ -988,6 +992,21 @@ pub(crate) fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str victims.len() } +/// The automation error code for each [`BlockServiceError`], so a headless +/// client branches on a stable machine-readable code while the text stays the +/// one the shared formatter produced. +fn block_error_code(error: &crate::scene::block_service::BlockServiceError) -> &'static str { + use crate::scene::block_service::BlockServiceError as E; + match error { + E::NotFound(_) => "block_missing", + E::InvalidName(_) => "block_invalid_name", + E::NameExists(_) => "block_exists", + E::Referenced { .. } => "block_referenced", + E::InEditSession(_) => "block_in_edit", + E::Protected(_) => "block_protected", + } +} + /// `data:[{code,value},…]` → typed XData values; an absent or empty list /// means "remove this application's record". fn parse_xdata_values(req: &Value) -> Parsed>> { diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index e1deef2ac..4688ea496 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1631,7 +1631,6 @@ mod actions; pub(crate) mod vision; pub(crate) use actions::property_json; mod entities; -pub(crate) use entities::erase_block_definition; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod http_bridge; mod interactive; diff --git a/src/app/control/p1_tests.rs b/src/app/control/p1_tests.rs index e7e891327..cf6633b88 100644 --- a/src/app/control/p1_tests.rs +++ b/src/app/control/p1_tests.rs @@ -551,11 +551,29 @@ fn block_delete_and_define_replace_manage_definitions() { assert_eq!(count_type(&mut app, "Line"), 0, "old children erased"); assert_eq!(count_type(&mut app, "Circle"), 0, "old children erased"); + // A definition the insert still points at is refused; "force":true erases + // the referencing inserts in the same undo step. + let r = app.automation_op(&format!( + r#"{{"protocol":1,"op":"block_delete","request_id":"bl1b","document_id":{doc},"name":"MARK"}}"# + )); + assert_eq!(r["ok"], false); + assert_eq!(r["code"], "block_referenced"); + assert_eq!( + count_type(&mut app, "Insert"), + 1, + "the insert survived the refusal" + ); + // Delete removes the insert, the markers, the children and the record. let r = app.automation_op(&format!( - r#"{{"protocol":1,"op":"block_delete","request_id":"bl2","document_id":{doc},"name":"MARK"}}"# + r#"{{"protocol":1,"op":"block_delete","request_id":"bl2","document_id":{doc},"name":"MARK","force":true}}"# )); assert_eq!(r["ok"], true, "{}", r["error"]); + assert_eq!( + r["result"]["references"].as_u64().unwrap(), + 1, + "the forced delete reports what it overrode" + ); assert!( r["result"]["erased"].as_u64().unwrap() >= 3, "insert + markers + child" diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 34a965576..25e1d7aa9 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -2337,80 +2337,77 @@ impl<'a> HostSession<'a> { 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()); + let from = from.trim().to_owned(); + let to = to.trim().to_owned(); + let open_edits = self.open_block_edits(); + let handle = self + .document() + .block_records + .get(&from) + .map(|record| record.handle) + .unwrap_or(Handle::NULL); + self.push_undo(&format!("Rename block {from} to {to}")); + match crate::scene::block_service::rename( + &mut self.app.tabs[self.tab].scene, + &from, + &to, + &open_edits, + ) { + Ok(_) => { + self.finish_block_change(); + Ok(handle) + } + Err(error) => { + self.app.discard_last_undo_entry(self.tab); + Err(error.to_string()) + } } - 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) + let name = name.trim().to_owned(); + // The external API has no force flag, so a referenced block is + // always refused here — same rule as the palette. + let open_edits = self.open_block_edits(); + let handle = self + .document() + .block_records + .get(&name) + .map(|record| record.handle) + .unwrap_or(Handle::NULL); + self.push_undo(&format!("Delete block {name}")); + match crate::scene::block_service::delete( + &mut self.app.tabs[self.tab].scene, + &name, + crate::scene::block_service::DeleteOptions { + force: false, + open_edits: &open_edits, + }, + ) { + Ok(_) => { + self.finish_block_change(); + Ok(handle) + } + Err(error) => { + self.app.discard_last_undo_entry(self.tab); + Err(error.to_string()) } - _ => 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 { - codec::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()), } } + /// Block records this document has open in a BEDIT session. Nested + /// sessions all count, so the whole stack is handed to the block service, + /// which refuses delete/rename and skips PURGE for every one of them. + fn open_block_edits(&self) -> Vec { + self.app.tabs[self.tab] + .block_edits + .iter() + .map(|session| session.br_handle) + .collect() + } + fn finish_block_change(&mut self) { self.app.tabs[self.tab].dirty = true; self.app.tabs[self.tab].scene.invalidate_dependency_index(); diff --git a/src/app/update/block_ops_tests.rs b/src/app/update/block_ops_tests.rs new file mode 100644 index 000000000..2dc81cacb --- /dev/null +++ b/src/app/update/block_ops_tests.rs @@ -0,0 +1,1349 @@ +//! Block delete / rename / purge characterisation. Tests only; +//! no production code changed. +//! +//! Paths covered: +//! * command line — `RENAME BLOCK old new` (and the absence of a +//! block-definition DELETE on the command line) +//! * Blocks palette — `MenuAction::Remove` (there is no rename action) +//! * plugin API — `HostSession::table_operation(BlockDelete / BlockRename)` +//! * control/"MCP" — `{"protocol":1,"op":"block_delete"}` +//! * PURGE — `PURGE BLOCKS` +//! +//! Every scenario is built in code; expected values are hand-written constants. +//! Naming follows the BEDIT convention: `locked_*` for behaviour we want to +//! keep, `documenting_*` for behaviour we expect to change. +//! +//! Every path goes through the shared block service +//! (`crate::scene::block_service`). The `documenting_*` cells whose behaviour +//! changed on purpose were converted to `locked_*` tests pinning the new, +//! shared outcome. + +use crate::app::OpenCADStudio; +use codec::entities::{ + Block as BlockEntity, BlockEnd, Dimension, DimensionAligned, Insert, LeaderContentType, Line, + MultiLeader, +}; +use codec::objects::Dictionary; +use codec::tables::{BlockRecord, DimStyle}; +use codec::types::{Handle, Vector3}; +use codec::EntityType; + +fn app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app +} + +fn tab(app: &OpenCADStudio) -> usize { + app.active_tab +} + +fn line(start: [f64; 3], end: [f64; 3]) -> EntityType { + EntityType::Line(Line::from_points( + Vector3::new(start[0], start[1], start[2]), + Vector3::new(end[0], end[1], end[2]), + )) +} + +/// One block definition holding a single LINE (0,0)-(1,0). +fn define_block(app: &mut OpenCADStudio, name: &str) -> Handle { + let i = tab(app); + app.tabs[i].scene.define_block_raw( + name, + Vector3::ZERO, + vec![line([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])], + ); + record_handle(app, name) +} + +/// A block definition built by hand, so `*` / xref / layout records can be +/// named exactly (the codec's table does not police the `*` prefix). +fn define_raw_block(app: &mut OpenCADStudio, name: &str, mut record: BlockRecord) -> Handle { + let i = tab(app); + let doc = &mut app.tabs[i].scene.document; + let next = doc.next_handle(); + let br = Handle::new(next); + let block = Handle::new(next + 1); + let end = Handle::new(next + 2); + record.handle = br; + record.block_entity_handle = block; + record.block_end_handle = end; + doc.block_records.add(record).unwrap(); + let mut marker = BlockEntity::new(name, Vector3::ZERO); + marker.common.handle = block; + marker.common.owner_handle = br; + doc.add_entity(EntityType::Block(marker)).unwrap(); + let mut block_end = BlockEnd::new(); + block_end.common.handle = end; + block_end.common.owner_handle = br; + doc.add_entity(EntityType::BlockEnd(block_end)).unwrap(); + let mut entity = Line::from_points(Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0)); + entity.common.owner_handle = br; + doc.add_entity(EntityType::Line(entity)).unwrap(); + br +} + +fn record_handle(app: &OpenCADStudio, name: &str) -> Handle { + app.tabs[tab(app)] + .scene + .document + .block_records + .get(name) + .map(|r| r.handle) + .unwrap_or_else(|| panic!("block {name} is not defined")) +} + +fn record_exists(app: &OpenCADStudio, name: &str) -> bool { + app.tabs[tab(app)] + .scene + .document + .block_records + .get(name) + .is_some() +} + +fn add_insert(app: &mut OpenCADStudio, block: &str) -> Handle { + let i = tab(app); + let insert = Insert::new(block.to_string(), Vector3::new(5.0, 5.0, 0.0)); + app.tabs[i].scene.add_entity(EntityType::Insert(insert)) +} + +/// INSERTs of any kind referencing `name`, in model space and inside block +/// definitions alike (the document's flat entity list). +fn insert_count(app: &OpenCADStudio, name: &str) -> usize { + app.tabs[tab(app)] + .scene + .document + .entities() + .filter( + |e| matches!(e, EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(name)), + ) + .count() +} + +fn entity_count(app: &OpenCADStudio) -> usize { + app.tabs[tab(app)].scene.document.entities().count() +} + +fn errors(app: &OpenCADStudio) -> Vec { + app.command_line + .history + .iter() + .filter(|entry| entry.kind == crate::ui::command_line::EntryKind::Error) + .map(|entry| entry.text.clone()) + .collect() +} + +fn outputs(app: &OpenCADStudio) -> Vec { + app.command_line + .history + .iter() + .filter(|entry| entry.kind == crate::ui::command_line::EntryKind::Output) + .map(|entry| entry.text.clone()) + .collect() +} + +/// The label of the step a path pushed. `push_undo_snapshot` parks the entry +/// in `history.pending` until something finishes it, so a fresh step may not +/// be on the stack yet. +fn last_undo_label(app: &OpenCADStudio) -> Option { + let history = &app.tabs[tab(app)].history; + history + .undo_stack + .last() + .map(|snapshot| snapshot.label().to_string()) + .or_else(|| { + history + .pending + .as_ref() + .map(|pending| pending.label.clone()) + }) +} + +fn undo_entry_count(app: &OpenCADStudio) -> usize { + let history = &app.tabs[tab(app)].history; + history.undo_stack.len() + usize::from(history.pending.is_some()) +} + +fn undo(app: &mut OpenCADStudio) { + let _ = app.update(crate::app::Message::Undo); +} + +// ── scenario fixtures ──────────────────────────────────────────────────── + +/// 1 unreferenced block. +fn fixture_unreferenced(app: &mut OpenCADStudio) -> String { + let name = "BO_ONE"; + define_block(app, name); + name.to_string() +} + +/// 1b a second unreferenced block under a different name, so a rename +/// collision needs two distinct records. +fn fixture_other_unreferenced(app: &mut OpenCADStudio) -> String { + let name = "BO_OTHER"; + define_block(app, name); + name.to_string() +} + +/// 2 referenced by a model-space INSERT. +fn fixture_model_space_reference(app: &mut OpenCADStudio) -> String { + let name = "BO_TWO"; + define_block(app, name); + add_insert(app, name); + name.to_string() +} + +/// 3 referenced only by an INSERT nested inside another definition. +fn fixture_nested_reference(app: &mut OpenCADStudio) -> String { + let name = "BO_INNER"; + let outer = "BO_OUTER"; + define_block(app, name); + let inner_insert = Insert::new(name.to_string(), Vector3::ZERO); + let i = tab(app); + app.tabs[i].scene.define_block_raw( + outer, + Vector3::ZERO, + vec![EntityType::Insert(inner_insert)], + ); + name.to_string() +} + +/// 4 referenced by a dimension's arrow block (the dimension-style reference +/// family, the same relationship a table's anonymous block has with its +/// contents). +fn fixture_dimension_reference(app: &mut OpenCADStudio) -> String { + let name = "BO_ARROW"; + let br = define_block(app, name); + let i = tab(app); + let mut style = DimStyle::new("BO_ARROWSTYLE"); + style.dimblk = br; + app.tabs[i].scene.document.dim_styles.add(style).unwrap(); + let mut dimension = + DimensionAligned::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + dimension.base.style_name = "BO_ARROWSTYLE".to_string(); + app.tabs[i] + .scene + .add_entity(EntityType::Dimension(Dimension::Aligned(dimension))); + name.to_string() +} + +/// 4b a dimension-style arrow block held by handle *and* by name, so a rename +/// has to follow the name-based reference too (a handle-based one survives on +/// its own). +fn fixture_dimension_name_reference(app: &mut OpenCADStudio) -> String { + let name = "BO_ARROWNAME"; + let br = define_block(app, name); + let i = tab(app); + let mut style = DimStyle::new("BO_ARROWNAMESTYLE"); + style.dimblk = br; + style.dimblk_name = name.to_string(); + app.tabs[i].scene.document.dim_styles.add(style).unwrap(); + let mut dimension = + DimensionAligned::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + dimension.base.style_name = "BO_ARROWNAMESTYLE".to_string(); + app.tabs[i] + .scene + .add_entity(EntityType::Dimension(Dimension::Aligned(dimension))); + name.to_string() +} + +/// 6 a multi-leader's block content. +fn fixture_multileader_reference(app: &mut OpenCADStudio) -> String { + let name = "BO_ML"; + let br = define_block(app, name); + let i = tab(app); + let mut leader = MultiLeader::new(); + leader.content_type = LeaderContentType::Block; + leader.context.has_block_contents = true; + leader.block_content_handle = Some(br); + app.tabs[i] + .scene + .add_entity(EntityType::MultiLeader(Box::new(leader))); + name.to_string() +} + +/// 5 anonymous block, unreferenced. +fn fixture_anonymous(app: &mut OpenCADStudio) -> String { + let name = "*BO_ANON"; + define_raw_block(app, name, BlockRecord::new(name.to_string())); + name.to_string() +} + +/// 8 a layout (*) block record, under the name the table stores it as (the +/// service reports the stored name in its messages). +fn fixture_layout(app: &mut OpenCADStudio) -> String { + app.tabs[tab(app)] + .scene + .document + .block_records + .get("*Model_Space") + .map(|record| record.name.clone()) + .expect("a model space record") +} + +/// 9 xref block. +fn fixture_xref(app: &mut OpenCADStudio) -> String { + let name = "BO_XREF"; + define_block(app, name); + let i = tab(app); + app.tabs[i] + .scene + .document + .block_records + .get_mut(name) + .unwrap() + .flags + .is_xref = true; + name.to_string() +} + +/// 10 a dynamic block: the record's extension dictionary carries an +/// `ACAD_ENHANCEDBLOCK` entry whose variants dictionary sources a `*U` +/// anonymous record. Both records must survive every delete and purge path. +fn attach_dynamic_block(app: &mut OpenCADStudio, record: &str) -> (Handle, Handle) { + let record_handle = record_handle(app, record); + // The *U anonymous record the variants dictionary sources. + let variant_handle = define_raw_block( + app, + "*U_BO_VARIANT", + BlockRecord::new("*U_BO_VARIANT".to_string()), + ); + let i = tab(app); + let doc = &mut app.tabs[i].scene.document; + let xdict = doc.allocate_handle(); + let variants = doc.allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = xdict; + dictionary.owner = record_handle; + dictionary.add_entry("ACAD_ENHANCEDBLOCK", variants); + doc.objects + .insert(xdict, codec::objects::ObjectType::Dictionary(dictionary)); + let mut variants_dictionary = Dictionary::new(); + variants_dictionary.handle = variants; + variants_dictionary.owner = xdict; + variants_dictionary.add_entry("VisibilityState1", variant_handle); + doc.objects.insert( + variants, + codec::objects::ObjectType::Dictionary(variants_dictionary), + ); + (record_handle, variant_handle) +} + +/// A fixture dynamic block: `BO_DYN` plus its `*U` variant record. +fn fixture_dynamic_block(app: &mut OpenCADStudio) -> String { + let name = "BO_DYN"; + define_block(app, name); + attach_dynamic_block(app, name); + name.to_string() +} + +// ── path drivers ───────────────────────────────────────────────────────── + +fn palette_remove(app: &mut OpenCADStudio, name: &str) { + let _ = app.on_block_palette(crate::ui::window::block_palette::BlockPaletteMsg::Menu( + crate::ui::window::block_palette::Item::Current(name.to_string()), + crate::ui::window::block_palette::MenuAction::Remove, + )); +} + +#[cfg(not(target_arch = "wasm32"))] +fn plugin_block_delete(app: &mut OpenCADStudio, name: &str) -> Result { + use ocs_plugin_api::host::TableOperation; + let i = tab(app); + let mut host = crate::app::plugin_host::HostSession::new(app, i); + host.table_operation(TableOperation::BlockDelete { + name: name.to_string(), + }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn plugin_block_rename(app: &mut OpenCADStudio, from: &str, to: &str) -> Result { + use ocs_plugin_api::host::TableOperation; + let i = tab(app); + let mut host = crate::app::plugin_host::HostSession::new(app, i); + host.table_operation(TableOperation::BlockRename { + from: from.to_string(), + to: to.to_string(), + }) +} + +fn control_request(app: &mut OpenCADStudio, mut req: serde_json::Value) -> serde_json::Value { + use serde_json::json; + static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let state = app.control_state(); + req["protocol"] = json!(1); + req["document_id"] = state["document_id"].clone(); + req["revision"] = state["revision"].clone(); + req["client_id"] = json!("test"); + if req.get("request_id").is_none() { + let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + req["request_id"] = json!(format!("req-{id}")); + } + let (response, task) = app.control_request(req.clone()); + app.drive_headless_task(task).unwrap(); + if matches!(response["status"].as_str(), Some("accepted" | "running")) { + app.control_request(json!({"op":"operation","request_id":req["request_id"]})) + .0 + } else { + response + } +} + +fn mcp_block_delete(app: &mut OpenCADStudio, name: &str) -> serde_json::Value { + use serde_json::json; + let req = json!({"op":"block_delete","name":name}); + control_request(app, req) +} + +/// `"force": true` — the protocol change: it erases the +/// referencing INSERTs in the same undo step instead of refusing. +fn mcp_block_delete_forced(app: &mut OpenCADStudio, name: &str) -> serde_json::Value { + use serde_json::json; + let req = json!({"op":"block_delete","name":name,"force":true}); + control_request(app, req) +} + +fn begin_bedit(app: &mut OpenCADStudio, handle: Handle) { + let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", handle.value())); +} + +/// The exact text the shared service formatter produces, asserted by every +/// path so the matrix cannot drift apart again. +fn referenced_text(name: &str, count: usize) -> String { + format!("block \"{name}\" is referenced {count} time(s) in the drawing and cannot be removed") +} + +/// What the command line shows for one of the service's messages: the text +/// itself plus the `INVALID:` decoration `CommandLine::push_error` adds. The +/// palette and `RENAME` print through that line, so their cells carry the +/// decoration; the plugin and control paths hand the raw text back. +fn command_line_error(text: &str) -> String { + format!("✕ INVALID: {text}") +} + +fn bedit_open(app: &OpenCADStudio) -> bool { + app.tabs[tab(app)].scene.block_edit_block.is_some() +} + +// ── command line: RENAME ───────────────────────────────────────────────── + +#[test] +fn locked_rename_block_repoints_inserts_and_undo_restores_the_name() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + let insert = add_insert(&mut app, &name); + let _ = app.dispatch_command(&format!("RENAME BLOCK {name} BO_ONE_RENAMED")); + assert!( + !errors(&app).iter().any(|e| e.contains("RENAME")), + "unexpected RENAME error: {:?}", + errors(&app) + ); + assert!(record_exists(&app, "BO_ONE_RENAMED")); + assert!(!record_exists(&app, &name)); + let repointed = app.tabs[tab(&app)] + .scene + .document + .get_entity(insert) + .and_then(|e| match e { + EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }); + assert_eq!(repointed.as_deref(), Some("BO_ONE_RENAMED")); + assert_eq!( + last_undo_label(&app).as_deref(), + Some("Rename block BO_ONE to BO_ONE_RENAMED") + ); + assert!( + outputs(&app) + .iter() + .any(|o| o.contains("RENAME") && o.contains("BO_ONE_RENAMED")), + "no RENAME success line: {:?}", + outputs(&app) + ); + undo(&mut app); + assert!(record_exists(&app, &name)); + assert!(!record_exists(&app, "BO_ONE_RENAMED")); + let restored = app.tabs[tab(&app)] + .scene + .document + .get_entity(insert) + .and_then(|e| match e { + EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }); + assert_eq!(restored.as_deref(), Some(name.as_str())); +} + +#[test] +fn locked_rename_to_an_existing_name_is_refused_with_the_shared_text_leaving_no_undo_entry() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + let other = fixture_other_unreferenced(&mut app); + let before = undo_entry_count(&app); + let insert_count_before = insert_count(&app, &name); + let _ = app.dispatch_command(&format!("RENAME BLOCK {name} {other}")); + assert!(record_exists(&app, &name)); + assert!(record_exists(&app, &other)); + assert_eq!(insert_count(&app, &name), insert_count_before); + assert_eq!( + undo_entry_count(&app), + before, + "a refused RENAME must not leave an undo entry" + ); + assert_eq!( + errors(&app), + vec![command_line_error(&format!( + "block \"{other}\" already exists" + ))] + ); + assert!( + !outputs(&app).iter().any(|o| o.contains("RENAME")), + "a success line was printed for a refused rename: {:?}", + outputs(&app) + ); +} + +#[test] +fn locked_rename_to_an_invalid_name_is_refused_with_the_shared_text() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + let before = undo_entry_count(&app); + let _ = app.dispatch_command(&format!("RENAME BLOCK {name} bad/name")); + assert!(record_exists(&app, &name)); + assert!(!record_exists(&app, "bad/name")); + assert_eq!(undo_entry_count(&app), before); + assert_eq!( + errors(&app), + vec![command_line_error("a block name cannot contain '/'")] + ); +} + +/// A rename follows a name-based reference wherever it sits: an INSERT nested +/// inside another definition repoints like a model-space one. +#[test] +fn locked_rename_follows_an_insert_nested_inside_another_definition() { + let mut app = app(); + let name = fixture_nested_reference(&mut app); + let nested = app.tabs[tab(&app)] + .scene + .document + .entities() + .find(|e| matches!(e, EntityType::Insert(ins) if ins.block_name == name)) + .map(|e| e.common().handle) + .expect("the nested INSERT"); + let _ = app.dispatch_command(&format!("RENAME BLOCK {name} BO_INNER_RENAMED")); + assert!(record_exists(&app, "BO_INNER_RENAMED")); + assert!(!record_exists(&app, &name)); + let repointed = app.tabs[tab(&app)] + .scene + .document + .get_entity(nested) + .and_then(|e| match e { + EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }); + assert_eq!(repointed.as_deref(), Some("BO_INNER_RENAMED")); + assert_eq!( + last_undo_label(&app).as_deref(), + Some("Rename block BO_INNER to BO_INNER_RENAMED") + ); + undo(&mut app); + assert!(record_exists(&app, &name)); + let restored = app.tabs[tab(&app)] + .scene + .document + .get_entity(nested) + .and_then(|e| match e { + EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }); + assert_eq!(restored.as_deref(), Some(name.as_str())); +} + +/// A dimension-style arrow block held by name follows the rename too; the +/// handle-based reference keeps working because the record keeps its handle. +#[test] +fn locked_rename_repoints_a_dimension_arrow_block_name() { + let mut app = app(); + let name = fixture_dimension_name_reference(&mut app); + let _ = app.dispatch_command(&format!("RENAME BLOCK {name} BO_ARROWNAME_NEW")); + assert!(record_exists(&app, "BO_ARROWNAME_NEW")); + assert!(!record_exists(&app, &name)); + let i = tab(&app); + let style = app.tabs[i] + .scene + .document + .dim_styles + .get("BO_ARROWNAMESTYLE") + .unwrap(); + assert_eq!(style.dimblk_name, "BO_ARROWNAME_NEW"); + assert!( + style.dimblk.is_valid(), + "the handle-based arrow was dropped" + ); + assert_eq!( + last_undo_label(&app).as_deref(), + Some("Rename block BO_ARROWNAME to BO_ARROWNAME_NEW") + ); + undo(&mut app); + assert!(record_exists(&app, &name)); + assert_eq!( + app.tabs[tab(&app)] + .scene + .document + .dim_styles + .get("BO_ARROWNAMESTYLE") + .unwrap() + .dimblk_name, + name + ); +} + +/// A rename refuses the protected records with the shared text, one refusal +/// per record and no undo entry for either. +#[test] +fn locked_rename_refuses_anonymous_and_layout_records_with_the_shared_text() { + let mut app = app(); + let anonymous = fixture_anonymous(&mut app); + let layout = fixture_layout(&mut app); + let before = undo_entry_count(&app); + let _ = app.dispatch_command(&format!("RENAME BLOCK {anonymous} BO_NEW")); + let _ = app.dispatch_command(&format!("RENAME BLOCK {layout} BO_NEW")); + assert!(record_exists(&app, &anonymous)); + assert!(record_exists(&app, &layout)); + assert!(!record_exists(&app, "BO_NEW")); + assert_eq!(undo_entry_count(&app), before); + assert_eq!( + errors(&app), + vec![ + command_line_error(&format!("block \"{anonymous}\" is an anonymous block")), + command_line_error(&format!( + "block \"{layout}\" is a model or paper space layout record" + )), + ] + ); +} + +/// A rename refuses a block with an open BEDIT session, whatever the new name. +#[test] +fn locked_rename_refuses_a_block_with_an_open_bedit_session() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + let insert = add_insert(&mut app, &name); + begin_bedit(&mut app, insert); + let before = undo_entry_count(&app); + let _ = app.dispatch_command(&format!("RENAME BLOCK {name} BO_ONE_RENAMED")); + assert!(record_exists(&app, &name)); + assert!(!record_exists(&app, "BO_ONE_RENAMED")); + assert_eq!(undo_entry_count(&app), before); + assert_eq!( + errors(&app), + vec![command_line_error(&format!( + "block \"{name}\" is open in the block editor; close the editor first" + ))] + ); + let _ = app.dispatch_command("BEDIT_DISCARD"); +} + +/// A handle-based reference (a multi-leader's block content, a +/// dimension-style arrow handle) does not block a rename: the record keeps its +/// handle, so the reference keeps resolving. +#[test] +fn locked_rename_succeeds_when_only_a_handle_based_reference_exists() { + let mut app = app(); + let name = fixture_multileader_reference(&mut app); + let _ = app.dispatch_command(&format!("RENAME BLOCK {name} BO_ML_RENAMED")); + assert!(record_exists(&app, "BO_ML_RENAMED")); + assert!(!record_exists(&app, &name)); + assert_eq!( + last_undo_label(&app).as_deref(), + Some("Rename block BO_ML to BO_ML_RENAMED") + ); + let content = app.tabs[tab(&app)] + .scene + .document + .entities() + .find_map(|e| match e { + EntityType::MultiLeader(ml) => ml.block_content_handle, + _ => None, + }) + .expect("the multi-leader's block content"); + let renamed = app.tabs[tab(&app)] + .scene + .document + .block_records + .get("BO_ML_RENAMED") + .unwrap(); + assert_eq!( + renamed.handle, content, + "the handle-based reference no longer resolves" + ); + undo(&mut app); + assert!(record_exists(&app, &name)); +} + +#[test] +fn documenting_command_line_has_no_block_definition_delete() { + let mut app = app(); + let name = fixture_model_space_reference(&mut app); + let before = undo_entry_count(&app); + for command in ["DELETE", "BDELETE", "BLOCKDELETE", "PURGEBLOCK"] { + let _ = app.dispatch_command(command); + } + assert!( + record_exists(&app, &name), + "a command-line block delete would have removed {name}" + ); + assert_eq!( + undo_entry_count(&app), + before, + "no command created an undo entry for a block delete" + ); + assert!(insert_count(&app, &name) > 0); + // read from code: DELETE is a shortcut for DELETESELECTED + // (src/app/shortcuts.rs:50) and ERASE erases selected entities + // (src/app/commands/draw.rs:933); no command name deletes a definition. +} + +// ── Blocks palette ─────────────────────────────────────────────────────── + +#[test] +fn locked_palette_remove_deletes_an_unreferenced_block_with_a_delete_undo_label() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + palette_remove(&mut app, &name); + assert!(!record_exists(&app, &name), "the definition survived"); + assert_eq!( + last_undo_label(&app).as_deref(), + Some("Delete block BO_ONE") + ); + assert!( + outputs(&app).iter().any(|o| o.contains("removed")), + "no palette success line: {:?}", + outputs(&app) + ); + undo(&mut app); + assert!(record_exists(&app, &name)); +} + +#[test] +fn locked_palette_remove_refuses_a_model_space_referenced_block() { + let mut app = app(); + let name = fixture_model_space_reference(&mut app); + let entities_before = entity_count(&app); + let undo_before = undo_entry_count(&app); + palette_remove(&mut app, &name); + assert!(record_exists(&app, &name), "the definition was removed"); + assert_eq!(insert_count(&app, &name), 1, "the INSERT was erased"); + assert_eq!(entity_count(&app), entities_before); + assert_eq!( + undo_entry_count(&app), + undo_before, + "an undo entry was pushed" + ); + assert_eq!( + errors(&app), + vec![command_line_error(&referenced_text(&name, 1))], + "the palette must show the shared refusal text" + ); +} + +#[test] +fn locked_palette_remove_refuses_a_block_referenced_from_inside_a_definition() { + let mut app = app(); + let name = fixture_nested_reference(&mut app); + let undo_before = undo_entry_count(&app); + palette_remove(&mut app, &name); + assert!(record_exists(&app, &name)); + assert_eq!(insert_count(&app, &name), 1); + assert_eq!(undo_entry_count(&app), undo_before); + assert_eq!( + errors(&app), + vec![command_line_error(&referenced_text(&name, 1))], + "a nested INSERT is a reference like any other" + ); +} + +#[test] +fn locked_palette_remove_refuses_a_multileader_block_content_reference() { + let mut app = app(); + let name = fixture_multileader_reference(&mut app); + let undo_before = undo_entry_count(&app); + palette_remove(&mut app, &name); + assert!(record_exists(&app, &name)); + assert_eq!( + errors(&app), + vec![command_line_error(&referenced_text(&name, 1))], + "a multi-leader's block content is a reference like any other" + ); + assert_eq!(undo_entry_count(&app), undo_before); +} + +#[test] +fn locked_palette_remove_refuses_a_dimension_arrow_block_reference() { + let mut app = app(); + let name = fixture_dimension_reference(&mut app); + let undo_before = undo_entry_count(&app); + palette_remove(&mut app, &name); + assert!( + record_exists(&app, &name), + "the palette deleted a block a dimension style still points at" + ); + assert_eq!( + errors(&app), + vec![command_line_error(&referenced_text(&name, 2))], + "the dimension's arrow-block handle and the picture are references" + ); + assert_eq!(undo_entry_count(&app), undo_before); +} + +#[test] +fn locked_palette_remove_refuses_an_anonymous_block() { + let mut app = app(); + let name = fixture_anonymous(&mut app); + let undo_before = undo_entry_count(&app); + palette_remove(&mut app, &name); + assert!( + record_exists(&app, &name), + "the palette deleted an anonymous block" + ); + assert_eq!( + errors(&app), + vec![command_line_error(&format!( + "block \"{name}\" is an anonymous block" + ))] + ); + assert_eq!(undo_entry_count(&app), undo_before); +} + +#[test] +fn locked_palette_remove_refuses_the_model_space_record() { + let mut app = app(); + let name = fixture_layout(&mut app); + palette_remove(&mut app, &name); + assert!( + record_exists(&app, &name), + "the palette removed the model space record" + ); + assert_eq!( + errors(&app), + vec![command_line_error(&format!( + "block \"{name}\" is a model or paper space layout record" + ))] + ); +} + +#[test] +fn locked_palette_remove_refuses_an_xref_block() { + let mut app = app(); + let name = fixture_xref(&mut app); + palette_remove(&mut app, &name); + assert!(record_exists(&app, &name), "the palette deleted the xref"); + assert_eq!( + errors(&app), + vec![command_line_error(&format!( + "block \"{name}\" is an externally referenced block" + ))] + ); +} + +#[test] +fn locked_palette_remove_refuses_a_dynamic_block() { + let mut app = app(); + let name = fixture_dynamic_block(&mut app); + let undo_before = undo_entry_count(&app); + palette_remove(&mut app, &name); + assert!( + record_exists(&app, &name), + "the palette deleted a dynamic block" + ); + assert_eq!( + errors(&app), + vec![command_line_error(&format!( + "block \"{name}\" holds dynamic-block data" + ))] + ); + assert_eq!(undo_entry_count(&app), undo_before); +} + +#[test] +fn locked_palette_remove_refuses_a_block_with_an_open_bedit_session() { + let mut app = app(); + let name = fixture_model_space_reference(&mut app); + let insert = add_insert(&mut app, &name); + begin_bedit(&mut app, insert); + assert!(bedit_open(&app), "BEDIT did not start"); + let undo_before = undo_entry_count(&app); + // The session is the reason for the refusal, not the INSERT it edits. + palette_remove(&mut app, &name); + assert!(record_exists(&app, &name)); + assert_eq!(undo_entry_count(&app), undo_before); + assert_eq!( + errors(&app), + vec![command_line_error(&format!( + "block \"{name}\" is open in the block editor; close the editor first" + ))] + ); +} + +/// Nested BEDIT sessions all count: the outer and the inner record are both +/// open, so both are refused — the whole session stack is handed to the +/// service, not just the active tab. +#[test] +fn locked_palette_remove_refuses_every_block_of_a_nested_bedit_session() { + let mut app = app(); + let inner = "BO_NEST_INNER"; + define_block(&mut app, inner); + let outer = "BO_NEST_OUTER"; + let nested_insert = Insert::new(inner.to_string(), Vector3::ZERO); + let i = tab(&app); + app.tabs[i].scene.define_block_raw( + outer, + Vector3::ZERO, + vec![EntityType::Insert(nested_insert)], + ); + let outer_reference = add_insert(&mut app, outer); + let inner_reference = app.tabs[i] + .scene + .document + .entities() + .find(|e| matches!(e, EntityType::Insert(ins) if ins.block_name == inner)) + .map(|e| e.common().handle) + .expect("the nested INSERT"); + begin_bedit(&mut app, outer_reference); + begin_bedit(&mut app, inner_reference); + assert_eq!( + app.tabs[tab(&app)].block_edits.len(), + 2, + "the nested session did not open" + ); + for name in [outer, inner] { + palette_remove(&mut app, name); + assert!(record_exists(&app, name), "{name} went while open"); + let expected = command_line_error(&format!( + "block \"{name}\" is open in the block editor; close the editor first" + )); + assert_eq!( + errors(&app).last(), + Some(&expected), + "{name} was refused with the wrong text" + ); + } + let _ = app.dispatch_command("BEDIT_DISCARD"); +} + +// ── plugin API ─────────────────────────────────────────────────────────── + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn locked_plugin_block_delete_removes_an_unreferenced_block_and_undo_restores_it() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + let before = entity_count(&app); + assert!(plugin_block_delete(&mut app, &name).is_ok()); + assert!(!record_exists(&app, &name)); + assert!(entity_count(&app) < before, "the block's entities survived"); + assert_eq!( + last_undo_label(&app).as_deref(), + Some("Delete block BO_ONE") + ); + undo(&mut app); + assert!(record_exists(&app, &name)); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn locked_plugin_block_delete_refuses_a_model_space_reference() { + let mut app = app(); + let name = fixture_model_space_reference(&mut app); + let undo_before = undo_entry_count(&app); + let err = plugin_block_delete(&mut app, &name).expect_err("should refuse"); + assert_eq!(err, referenced_text(&name, 1)); + assert!(record_exists(&app, &name)); + assert_eq!(insert_count(&app, &name), 1); + assert_eq!(undo_entry_count(&app), undo_before); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn locked_plugin_block_delete_refuses_a_block_referenced_only_inside_a_definition() { + let mut app = app(); + let name = fixture_nested_reference(&mut app); + let err = plugin_block_delete(&mut app, &name).expect_err("should refuse"); + assert_eq!(err, referenced_text(&name, 1)); + assert!(record_exists(&app, &name)); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn locked_plugin_block_delete_refuses_a_dimension_arrow_block() { + let mut app = app(); + let name = fixture_dimension_reference(&mut app); + let err = plugin_block_delete(&mut app, &name).expect_err("should refuse"); + assert_eq!(err, referenced_text(&name, 2)); + assert!(record_exists(&app, &name)); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn locked_plugin_block_delete_refuses_anonymous_layout_and_xref_blocks() { + let mut app = app(); + let anonymous = fixture_anonymous(&mut app); + let err = plugin_block_delete(&mut app, &anonymous).expect_err("should refuse"); + assert_eq!(err, format!("block \"{anonymous}\" is an anonymous block")); + assert!(record_exists(&app, &anonymous)); + + let xref = fixture_xref(&mut app); + let err = plugin_block_delete(&mut app, &xref).expect_err("should refuse"); + assert_eq!( + err, + format!("block \"{xref}\" is an externally referenced block") + ); + assert!(record_exists(&app, &xref)); + + let layout = fixture_layout(&mut app); + let err = plugin_block_delete(&mut app, &layout).expect_err("should refuse"); + assert_eq!( + err, + format!("block \"{layout}\" is a model or paper space layout record") + ); + assert!(record_exists(&app, &layout)); +} + +/// The plugin API renames through the same service, so its refusals carry the +/// same text as the command line's and the palette's. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn locked_plugin_block_rename_refuses_anonymous_layout_and_xref_records() { + let mut app = app(); + let anonymous = fixture_anonymous(&mut app); + let err = + plugin_block_rename(&mut app, &anonymous, "BO_PLUGIN_RENAMED").expect_err("should refuse"); + assert_eq!(err, format!("block \"{anonymous}\" is an anonymous block")); + + let xref = fixture_xref(&mut app); + let err = plugin_block_rename(&mut app, &xref, "BO_PLUGIN_RENAMED").expect_err("should refuse"); + assert_eq!( + err, + format!("block \"{xref}\" is an externally referenced block") + ); + + let layout = fixture_layout(&mut app); + let err = + plugin_block_rename(&mut app, &layout, "BO_PLUGIN_RENAMED").expect_err("should refuse"); + assert_eq!( + err, + format!("block \"{layout}\" is a model or paper space layout record") + ); + for name in [anonymous, xref, layout] { + assert!(record_exists(&app, &name), "{name} was renamed"); + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn locked_plugin_block_rename_follows_inserts_and_refuses_taken_or_invalid_names() { + let mut app = app(); + let name = fixture_model_space_reference(&mut app); + let insert = add_insert(&mut app, &name); + let other = fixture_unreferenced(&mut app); + assert!(plugin_block_rename(&mut app, &name, "BO_PLUGIN_RENAMED").is_ok()); + assert_eq!( + last_undo_label(&app).as_deref(), + Some("Rename block BO_TWO to BO_PLUGIN_RENAMED") + ); + assert!(!record_exists(&app, &name)); + let repointed = app.tabs[tab(&app)] + .scene + .document + .get_entity(insert) + .and_then(|e| match e { + EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }); + assert_eq!(repointed.as_deref(), Some("BO_PLUGIN_RENAMED")); + + let before = undo_entry_count(&app); + let err = plugin_block_rename(&mut app, "BO_PLUGIN_RENAMED", &other) + .expect_err("should refuse a taken name"); + assert_eq!(err, format!("block \"{other}\" already exists")); + let err = plugin_block_rename(&mut app, "BO_PLUGIN_RENAMED", "bad/name") + .expect_err("should refuse an invalid name"); + assert_eq!(err, "a block name cannot contain '/'"); + let err = plugin_block_rename(&mut app, "BO_PLUGIN_RENAMED", "") + .expect_err("should refuse an empty name"); + assert_eq!(err, "a block name cannot be empty"); + assert_eq!(undo_entry_count(&app), before); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn locked_plugin_block_delete_refuses_a_block_with_an_open_bedit_session() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + // A BEDIT session can only be started on a reference, so insert one. + let insert = add_insert(&mut app, &name); + begin_bedit(&mut app, insert); + let err = plugin_block_delete(&mut app, &name).expect_err("should refuse"); + assert_eq!( + err, + format!("block \"{name}\" is open in the block editor; close the editor first") + ); + let _ = app.dispatch_command("BEDIT_DISCARD"); +} + +// ── control ("MCP") block_delete ───────────────────────────────────────── + +#[test] +fn locked_mcp_block_delete_refuses_a_referenced_block_without_force() { + let mut app = app(); + let name = fixture_model_space_reference(&mut app); + add_insert(&mut app, &name); + let undo_before = undo_entry_count(&app); + let response = mcp_block_delete(&mut app, &name); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["code"], "block_referenced", "{response}"); + assert_eq!(response["error"], referenced_text(&name, 2)); + assert!(record_exists(&app, &name)); + assert_eq!(insert_count(&app, &name), 2); + assert_eq!(undo_entry_count(&app), undo_before); +} + +#[test] +fn locked_mcp_block_delete_with_force_erases_referencing_inserts_and_undo_restores_them() { + let mut app = app(); + let name = fixture_model_space_reference(&mut app); + let insert = add_insert(&mut app, &name); + let response = mcp_block_delete_forced(&mut app, &name); + assert_eq!(response["ok"], true, "{response}"); + assert!(!record_exists(&app, &name), "the definition survived"); + assert_eq!(insert_count(&app, &name), 0, "the INSERTs survived"); + assert!(app.tabs[tab(&app)] + .scene + .document + .get_entity(insert) + .is_none()); + assert_eq!( + last_undo_label(&app).as_deref(), + Some(format!("Delete block {name}").as_str()) + ); + undo(&mut app); + assert!(record_exists(&app, &name)); + assert_eq!(insert_count(&app, &name), 2); + assert!(app.tabs[tab(&app)] + .scene + .document + .get_entity(insert) + .is_some()); +} + +#[test] +fn locked_mcp_block_delete_with_force_erases_inserts_nested_in_other_definitions() { + let mut app = app(); + let name = fixture_nested_reference(&mut app); + let response = mcp_block_delete_forced(&mut app, &name); + assert_eq!(response["ok"], true, "{response}"); + assert!(!record_exists(&app, &name)); + assert_eq!(insert_count(&app, &name), 0, "the nested INSERT survived"); + undo(&mut app); + assert!(record_exists(&app, &name)); + assert_eq!(insert_count(&app, &name), 1); +} + +#[test] +fn locked_mcp_block_delete_refuses_layout_xref_and_anonymous_records() { + let mut app = app(); + let layout = fixture_layout(&mut app); + let response = mcp_block_delete(&mut app, &layout); + assert_eq!(response["code"], "block_protected", "{response}"); + assert!(record_exists(&app, &layout)); + + let xref = fixture_xref(&mut app); + let response = mcp_block_delete_forced(&mut app, &xref); + assert_eq!(response["code"], "block_protected", "{response}"); + assert!(record_exists(&app, &xref), "the xref definition survived"); + + let anonymous = fixture_anonymous(&mut app); + let response = mcp_block_delete_forced(&mut app, &anonymous); + assert_eq!(response["code"], "block_protected", "{response}"); + assert!(record_exists(&app, &anonymous)); +} + +#[test] +fn locked_mcp_block_delete_refuses_a_dimension_arrow_block() { + let mut app = app(); + let name = fixture_dimension_reference(&mut app); + let response = mcp_block_delete(&mut app, &name); + assert_eq!(response["code"], "block_referenced", "{response}"); + assert_eq!(response["error"], referenced_text(&name, 2), "{response}"); + assert!(record_exists(&app, &name)); + assert!( + app.tabs[tab(&app)] + .scene + .document + .dim_styles + .iter() + .any(|style| style.dimblk.is_valid()), + "the dimension style kept its arrow block handle" + ); +} + +#[test] +fn locked_mcp_block_delete_reports_an_unknown_block() { + let mut app = app(); + let response = mcp_block_delete(&mut app, "BO_NOPE"); + assert_eq!(response["code"], "block_missing", "{response}"); +} + +#[test] +fn locked_mcp_block_delete_refuses_a_block_with_an_open_bedit_session() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + let insert = add_insert(&mut app, &name); + begin_bedit(&mut app, insert); + assert!(bedit_open(&app)); + let response = mcp_block_delete(&mut app, &name); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["code"], "block_in_edit", "{response}"); + assert_eq!( + response["error"], + format!("block \"{name}\" is open in the block editor; close the editor first") + ); + assert!(record_exists(&app, &name)); + let _ = app.dispatch_command("BEDIT_DISCARD"); +} + +/// `force` erases referencing INSERTs, not the other protections: an open +/// BEDIT session still refuses. +#[test] +fn locked_mcp_block_delete_with_force_still_refuses_an_open_bedit_session() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + let insert = add_insert(&mut app, &name); + begin_bedit(&mut app, insert); + let response = mcp_block_delete_forced(&mut app, &name); + assert_eq!(response["ok"], false, "{response}"); + assert_eq!(response["code"], "block_in_edit", "{response}"); + assert!(record_exists(&app, &name)); + let _ = app.dispatch_command("BEDIT_DISCARD"); +} + +// ── PURGE ──────────────────────────────────────────────────────────────── + +#[test] +fn locked_purge_removes_an_unreferenced_block_and_undo_restores_it() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + let before = entity_count(&app); + let _ = app.dispatch_command("PURGE BLOCKS"); + assert!(!record_exists(&app, &name), "the definition survived"); + assert!(entity_count(&app) < before); + assert_eq!(last_undo_label(&app).as_deref(), Some("Purge")); + assert!( + outputs(&app).iter().any(|o| o.contains("PURGE:")), + "no PURGE success line: {:?}", + outputs(&app) + ); + undo(&mut app); + assert!(record_exists(&app, &name)); +} + +#[test] +fn locked_purge_keeps_a_model_space_referenced_block() { + let mut app = app(); + let model_space = fixture_model_space_reference(&mut app); + let dimension = fixture_dimension_reference(&mut app); + let _ = app.dispatch_command("PURGE BLOCKS"); + assert!( + record_exists(&app, &model_space), + "purged a model-space referenced block" + ); + assert_eq!(insert_count(&app, &model_space), 1); + assert!( + record_exists(&app, &dimension), + "purged a dimension arrow block" + ); +} + +#[test] +fn locked_purge_removes_a_block_referenced_only_inside_an_unreferenced_definition() { + // An INSERT inside an unreferenced definition is a reference like any + // other, so the inner block is skipped on the first pass; the outer + // definition goes (nothing references it), and only once it is gone does + // the inner block qualify on the next pass. Both records end up removed, + // exactly as before the shared service, but now for one shared reason. + let mut app = app(); + let nested = fixture_nested_reference(&mut app); + let _ = app.dispatch_command("PURGE BLOCKS"); + assert!( + !record_exists(&app, &nested), + "the nested-only block was kept" + ); + assert!( + !record_exists(&app, "BO_OUTER"), + "the outer definition was kept" + ); + assert_eq!(insert_count(&app, &nested), 0); +} + +#[test] +fn locked_purge_removes_an_unreferenced_anonymous_block() { + let mut app = app(); + let name = fixture_anonymous(&mut app); + let _ = app.dispatch_command("PURGE BLOCKS"); + assert!(!record_exists(&app, &name), "the anonymous block survived"); +} + +/// The unsafe anonymous case: an INSERT references the anonymous record, so +/// PURGE keeps it exactly like any other referenced block. +#[test] +fn locked_purge_keeps_a_referenced_anonymous_block() { + let mut app = app(); + let name = fixture_anonymous(&mut app); + add_insert(&mut app, &name); + let _ = app.dispatch_command("PURGE BLOCKS"); + assert!( + record_exists(&app, &name), + "purge removed a referenced anonymous block" + ); + assert_eq!(insert_count(&app, &name), 1); +} + +/// A dynamic block and the `*U` record its variants dictionary sources are +/// both unreferenced, yet both stay: without the dictionary walk the +/// anonymous `*U` record would qualify on its own. +#[test] +fn locked_purge_keeps_a_dynamic_block_and_its_u_variant() { + let mut app = app(); + let name = fixture_dynamic_block(&mut app); + let _ = app.dispatch_command("PURGE BLOCKS"); + assert!(record_exists(&app, &name), "purged a dynamic block"); + assert!( + record_exists(&app, "*U_BO_VARIANT"), + "purged the *U record the dynamic block sources" + ); +} + +#[test] +fn locked_purge_keeps_xref_and_layout_records() { + let mut app = app(); + let xref = fixture_xref(&mut app); + let layout = fixture_layout(&mut app); + let _ = app.dispatch_command("PURGE BLOCKS"); + assert!(record_exists(&app, &xref), "purged an xref definition"); + assert!(record_exists(&app, &layout), "purged a layout record"); +} + +#[test] +fn locked_purge_skips_a_block_with_an_open_bedit_session() { + let mut app = app(); + let name = fixture_unreferenced(&mut app); + let insert = add_insert(&mut app, &name); + begin_bedit(&mut app, insert); + let _ = app.dispatch_command("PURGE BLOCKS"); + // The BEDIT session's own reference keeps the block live through the + // edited block record, so the purge sees it as reachable. + assert!(record_exists(&app, &name)); + let _ = app.dispatch_command("BEDIT_DISCARD"); +} diff --git a/src/app/update/blocks_palette.rs b/src/app/update/blocks_palette.rs index 554dcc328..44920a950 100644 --- a/src/app/update/blocks_palette.rs +++ b/src/app/update/blocks_palette.rs @@ -491,20 +491,31 @@ impl OpenCADStudio { Tab::Current => { let name = item.name(); let i = self.active_tab; - let referenced = self.tabs[i].scene.document.entities().any(|e| { - matches!(e, codec::EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(&name)) - }); - if referenced { - self.command_line.push_error(&crate::tf!( - "Block \"{name}\" is referenced in the drawing and cannot be removed." - )); - } else { - self.push_undo_snapshot(i, "PURGE"); - crate::app::control::erase_block_definition(&mut self.tabs[i].scene, &name); - self.tabs[i].dirty = true; - self.command_line - .push_output(&crate::tf!("Block \"{name}\" removed.")); - self.refresh_block_palette(); + let open_edits: Vec = self.tabs[i] + .block_edits + .iter() + .map(|session| session.br_handle) + .collect(); + self.push_undo_snapshot(i, format!("Delete block {name}")); + let outcome = crate::scene::block_service::delete( + &mut self.tabs[i].scene, + &name, + crate::scene::block_service::DeleteOptions { + force: false, + open_edits: &open_edits, + }, + ); + match outcome { + Ok(_) => { + self.tabs[i].dirty = true; + self.command_line + .push_output(&crate::tf!("Block \"{name}\" removed.")); + self.refresh_block_palette(); + } + Err(error) => { + self.discard_last_undo_entry(i); + self.command_line.push_error(&error.to_string()); + } } } Tab::Recent | Tab::Favorites => { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 00ff5c49e..18e1137c9 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -93,6 +93,8 @@ fn reorder_insertion_index(from: usize, to: usize, after: bool, len: usize) -> O (insertion != from).then_some(insertion) } +#[cfg(test)] +mod block_ops_tests; mod blocks_palette; mod command; mod context_menu; diff --git a/src/mcp.rs b/src/mcp.rs index 2665c59d2..4ffc27086 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -28,8 +28,10 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; /// The `tool_schema_digest_is_pinned` test fails whenever the MCP tool surface /// changes, so schema drift (new params, renamed tools) is always a conscious, /// reviewed edit — and clients can detect a stale bridge by comparing digests. +/// +/// `block_delete` gained its optional `force` parameter. #[cfg(test)] -const TOOL_SCHEMA_DIGEST: &str = "e7bd01696f57143d7139aeb75d53389450b1102b603f2ba4ace8122cc692070e"; +const TOOL_SCHEMA_DIGEST: &str = "c16572f139198d90807ede156370879106672ed97566c16ab6ae001c996059e8"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; diff --git a/src/mcp_ops.rs b/src/mcp_ops.rs index 72cb852ff..b1692d546 100644 --- a/src/mcp_ops.rs +++ b/src/mcp_ops.rs @@ -534,11 +534,18 @@ pub static BLOCK_DEFINE: OpDef = OpDef { pub static BLOCK_DELETE: OpDef = OpDef { name: "block_delete", - doc: "Delete a block definition and all its child entities and inserts.", + doc: "Delete a block definition and all its child entities. A referenced block is refused; force erases the referencing inserts.", batchable: true, - params: &[req("name", Ty::Str, "Block name to delete.")], + params: &[ + req("name", Ty::Str, "Block name to delete."), + opt( + "force", + Ty::Bool, + "Erase the referencing inserts instead of refusing a referenced block. Result reports how many references were overridden.", + ), + ], rules: &[], - example: r#"{"op":"block_delete","name":"DOOR"}"#, + example: r#"{"op":"block_delete","name":"DOOR","force":true}"#, }; pub static XDATA_SET: OpDef = OpDef { diff --git a/src/rest.rs b/src/rest.rs index 9498cc3a5..8afe80533 100644 --- a/src/rest.rs +++ b/src/rest.rs @@ -676,11 +676,22 @@ pub(crate) fn plan(method: &str, rest: &[&str], request: &HttpRequest) -> Plan { fields: body, created: 201, }, - ("DELETE", ["blocks", name]) => Plan::Mutate { - op: "block_delete".into(), - fields: json!({"name": name}), - created: 200, - }, + ("DELETE", ["blocks", name]) => { + // `?force=true` mirrors the control op: it erases the referencing + // inserts instead of refusing a referenced definition. + let mut fields = json!({"name": name}); + if request + .param("force") + .is_some_and(|value| value == "true" || value == "1") + { + fields["force"] = json!(true); + } + Plan::Mutate { + op: "block_delete".into(), + fields, + created: 200, + } + } ("POST", ["file-identity"]) => Plan::Mutate { op: "file_identity".into(), fields: body, @@ -1440,13 +1451,21 @@ mod tests { &mut counter, ); assert_eq!(status, 201, "{body}"); + // A definition the insert still points at is refused without force. let (status, body) = route( &mut app, &request("DELETE", "/api/v1/blocks/MARK", ""), &mut document_id, &mut counter, ); + assert_eq!(status, 400, "{body}"); + assert_eq!(body["code"], "block_referenced", "{body}"); + // `?force=true` erases the referencing insert in the same step. + let mut forced = request("DELETE", "/api/v1/blocks/MARK", ""); + forced.query = vec![("force".into(), "true".into())]; + let (status, body) = route(&mut app, &forced, &mut document_id, &mut counter); assert_eq!(status, 200, "{body}"); + assert_eq!(body["result"]["references"], 1, "{body}"); assert!(body["result"]["erased"].as_u64().unwrap() >= 1); let (status, body) = route( &mut app, diff --git a/src/scene/block_service.rs b/src/scene/block_service.rs new file mode 100644 index 000000000..0f646ead9 --- /dev/null +++ b/src/scene/block_service.rs @@ -0,0 +1,1100 @@ +//! The shared block-definition service: delete, rename and purge. +//! +//! Every path that removes or renames a block definition — the command line +//! (`RENAME BLOCK`), the Blocks palette, the plugin API +//! (`TableOperation::BlockDelete` / `BlockRename`), the control/"MCP" +//! `block_delete` op and `PURGE` — routes through this module so the same +//! definition gets the same answer, the same error text and the same undo +//! label everywhere. +//! +//! The module lives next to [`crate::scene::entity`] rather than in +//! `src/app` because everything it needs is document/scene level: a +//! [`CadDocument`] for the reference scan and a [`Scene`] for the erase. The +//! callers own the app-level concerns (undo snapshots, command-line output, +//! panel refreshes). + +use codec::objects::ObjectType; +use codec::tables::BlockRecord; +use codec::types::Handle; +use codec::{CadDocument, EntityType}; +use std::collections::HashSet; +use std::fmt; + +use crate::scene::render_graph::{entity_block_uses, BlockRole, BlockUse}; +use crate::scene::Scene; + +/// One reference a definition has. The kind names the relationship, `source` +/// is the handle of the entity or object that holds it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlockReferenceKind { + /// An INSERT, in model space or nested inside another definition. + Insert, + /// A dimension's generated picture block. + DimensionPicture, + /// The anonymous picture block a table draws through. + TablePicture, + /// A block-valued table cell. + TableCell, + /// A multi-leader's block content. + MultiLeaderContent, + /// A dimension-style / leader / multi-leader arrow block. + ArrowHead, + /// A DGN line-style symbol block. + LineStyleSymbol, + /// Extension point for dynamic-block variant families (visibility states, + /// flip/stretch action sources, `*U` records). Detectors push these + /// variants through [`dynamic_variant_references`]; the enum carries the + /// catch-all so a new family never changes the call sites. + DynamicVariant, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BlockReference { + pub kind: BlockReferenceKind, + pub source: Handle, +} + +/// Every reference to `name`, of every kind any current path counts. +/// +/// The union of the paths' rules (see the reference matrix below): +/// * INSERTs anywhere in the flat document entity list — what the palette, +/// the control path and the plugin scan, +/// * dimension picture blocks, table pictures and block-valued table cells, +/// multi-leader block content and line-style symbols — what the render +/// graph (and so PURGE's reachability walk) counts, +/// * dimension-style / leader / multi-leader arrow blocks, by handle and by +/// name, plus multi-leader styles — the plugin's extra scan, +/// * dynamic-block variant families, through the extension point below. +pub fn references_of(document: &CadDocument, name: &str) -> Vec { + let Some(record) = document.block_records.get(name.trim()) else { + return Vec::new(); + }; + let stored = record.name.clone(); + let target = record.handle; + let mut out = Vec::new(); + for entity in document.entities() { + for use_ in entity_block_uses(document, entity, 1.0) { + if use_.covers(target, &stored) { + out.push(BlockReference { + kind: kind_of(use_.role), + source: entity.common().handle, + }); + } + } + } + collect_style_references(document, target, &stored, &mut out); + out.extend(dynamic_variant_references(document, record)); + out +} + +/// Extension point for dynamic-block variant families. A detector for one +/// family (visibility states, action sources, `*U` records) returns the +/// references it finds; the block itself stays protected regardless, via +/// [`dynamic_block_handles`], which is the conservative fallback while a +/// family has no detector. +pub fn dynamic_variant_references( + _document: &CadDocument, + _record: &BlockRecord, +) -> Vec { + Vec::new() +} + +fn kind_of(role: BlockRole) -> BlockReferenceKind { + match role { + BlockRole::Insert => BlockReferenceKind::Insert, + BlockRole::DimensionPicture => BlockReferenceKind::DimensionPicture, + BlockRole::TablePicture => BlockReferenceKind::TablePicture, + BlockRole::TableCell => BlockReferenceKind::TableCell, + BlockRole::MultiLeaderContent => BlockReferenceKind::MultiLeaderContent, + BlockRole::ArrowHead => BlockReferenceKind::ArrowHead, + BlockRole::LineStyleSymbol => BlockReferenceKind::LineStyleSymbol, + } +} + +impl BlockUse { + fn covers(&self, target: Handle, stored: &str) -> bool { + (!self.block.is_null() && self.block == target) + || self.insert.block_name.eq_ignore_ascii_case(stored) + } +} + +/// References held by document-level styles and objects rather than by an +/// entity: dimension-style arrow blocks (by handle and by name), +/// multi-leader styles and DGN line-style symbols. Each entry names the +/// object that holds the reference so a caller can report or repoint it. +fn collect_style_references( + document: &CadDocument, + target: Handle, + stored: &str, + out: &mut Vec, +) { + for style in document.dim_styles.iter() { + for handle in [style.dimblk, style.dimblk1, style.dimblk2, style.dimldrblk] { + if handle == target { + out.push(BlockReference { + kind: BlockReferenceKind::ArrowHead, + source: style.handle, + }); + } + } + for name in [&style.dimblk_name, &style.dimblk1_name, &style.dimblk2_name] { + if name.eq_ignore_ascii_case(stored) && document.block_records.get(name).is_some() { + out.push(BlockReference { + kind: BlockReferenceKind::ArrowHead, + source: style.handle, + }); + } + } + } + for (handle, object) in document.objects.iter() { + let ObjectType::MultiLeaderStyle(style) = object else { + continue; + }; + if style.arrowhead_handle == Some(target) { + out.push(BlockReference { + kind: BlockReferenceKind::ArrowHead, + source: *handle, + }); + } + if style.block_content_handle == Some(target) { + out.push(BlockReference { + kind: BlockReferenceKind::MultiLeaderContent, + source: *handle, + }); + } + } + for line_type in document.line_types.iter() { + for symbol in crate::scene::convert::dgn_linestyle::symbol_blocks(document, &line_type.name) + { + if symbol.block == target { + out.push(BlockReference { + kind: BlockReferenceKind::LineStyleSymbol, + source: line_type.handle, + }); + } + } + } +} + +/// Why an operation is refused. One [`fmt::Display`] on +/// [`BlockServiceError`] renders the user-facing text, so every path shows +/// exactly the same sentence for the same scenario. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BlockServiceError { + NotFound(String), + InvalidName(String), + NameExists(String), + Referenced { name: String, count: usize }, + InEditSession(String), + Protected(String), +} + +impl fmt::Display for BlockServiceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BlockServiceError::NotFound(name) => write!(f, "block \"{name}\" does not exist"), + BlockServiceError::InvalidName(message) => f.write_str(message), + BlockServiceError::NameExists(name) => write!(f, "block \"{name}\" already exists"), + BlockServiceError::Referenced { name, count } => write!( + f, + "block \"{name}\" is referenced {count} time(s) in the drawing and cannot be removed" + ), + BlockServiceError::InEditSession(name) => write!( + f, + "block \"{name}\" is open in the block editor; close the editor first" + ), + BlockServiceError::Protected(message) => f.write_str(message), + } + } +} + +impl std::error::Error for BlockServiceError {} + +fn missing(name: &str) -> BlockServiceError { + BlockServiceError::NotFound(name.to_owned()) +} + +/// The name rules the plugin path already applies to block names, now shared +/// by every path: 1-255 characters, no control characters and none of the +/// symbol-table-reserved characters (`*` marks anonymous blocks, `|` +/// xref-dependent ones; the rest are the DXF symbol-name exclusions). Names +/// are trimmed. Matching a name against the table is case-insensitive +/// (`Table::get` normalises to upper case). +pub fn validate_block_name(raw: &str) -> Result { + let name = raw.trim(); + if name.is_empty() { + return Err(BlockServiceError::InvalidName( + "a block name cannot be empty".to_owned(), + )); + } + if name.chars().count() > 255 { + return Err(BlockServiceError::InvalidName( + "a block name is limited to 255 characters".to_owned(), + )); + } + if let Some(bad) = name + .chars() + .find(|c| "<>/\\\":;?*|,=`".contains(*c) || c.is_control()) + { + return Err(BlockServiceError::InvalidName(format!( + "a block name cannot contain {bad:?}" + ))); + } + Ok(name.to_owned()) +} + +/// Records a direct delete or rename may never touch: the model / paper space +/// containers and layout blocks, externally referenced records and +/// dynamic-block records. +fn protected_reason( + document: &CadDocument, + record: &BlockRecord, + dynamic: &HashSet, +) -> Option { + if is_layout_record(document, record) { + return Some(format!( + "block \"{}\" is a model or paper space layout record", + record.name + )); + } + if record.flags.is_xref || record.flags.is_xref_overlay || record.name.contains('|') { + return Some(format!( + "block \"{}\" is an externally referenced block", + record.name + )); + } + if dynamic.contains(&record.handle) { + return Some(format!( + "block \"{}\" holds dynamic-block data", + record.name + )); + } + None +} + +/// A layout record: a named `*`-space container or one backed by a Layout +/// object (paper space layouts and any orphaned layout record). +fn is_layout_record(document: &CadDocument, record: &BlockRecord) -> bool { + let upper = record.name.to_ascii_uppercase(); + upper.starts_with("*MODEL_SPACE") + || upper.starts_with("*PAPER_SPACE") + || !record.layout.is_null() + || matches!( + document.objects.get(&record.layout), + Some(ObjectType::Layout(_)) + ) +} + +/// The extension-dictionary key on a dynamic block's record. +const ENHANCED_BLOCK_KEY: &str = "ACAD_ENHANCEDBLOCK"; + +/// Whether a dictionary key names the enhanced-block (dynamic block) record. +/// Keys longer than the prefix count, so `ACAD_ENHANCEDBLOCK_…` variants from +/// newer writers are caught too; the comparison is case-insensitive because +/// dictionary keys are not normalised. +fn is_enhanced_block_key(key: &str) -> bool { + key.len() >= ENHANCED_BLOCK_KEY.len() + && key[..ENHANCED_BLOCK_KEY.len()].eq_ignore_ascii_case(ENHANCED_BLOCK_KEY) +} + +/// Handles PURGE may not remove either: dynamic blocks (above), plus every +/// anonymous record a dynamic block sources its `*U` variants from. +fn dynamic_block_handles(document: &CadDocument) -> HashSet { + let mut protected: HashSet = HashSet::new(); + for record in document.block_records.iter() { + let Some(dictionary) = extension_dictionary(document, record.handle) else { + continue; + }; + if !dictionary + .entries + .iter() + .any(|(key, _)| is_enhanced_block_key(key)) + { + continue; + } + protected.insert(record.handle); + collect_dictionary_tree(document, dictionary.handle, &mut protected); + } + let records: HashSet = document.block_records.iter().map(|r| r.handle).collect(); + protected.retain(|handle| records.contains(handle)); + protected +} + +/// Every handle reachable from a dynamic block's extension dictionary: the +/// record itself plus the anonymous records its visibility-state dictionary +/// points at, so a `*U` record is never purged out from under its block. +fn collect_dictionary_tree(document: &CadDocument, handle: Handle, out: &mut HashSet) { + if !out.insert(handle) { + return; + } + let Some(ObjectType::Dictionary(dictionary)) = document.objects.get(&handle) else { + return; + }; + for (_, entry) in dictionary.entries.clone() { + collect_dictionary_tree(document, entry, out); + } +} + +fn extension_dictionary( + document: &CadDocument, + owner: Handle, +) -> Option<&codec::objects::Dictionary> { + match document + .extension_dictionary_handle(owner) + .and_then(|handle| document.objects.get(&handle))? + { + ObjectType::Dictionary(dictionary) => Some(dictionary), + _ => None, + } +} + +/// Blocks PURGE may remove: every record that is not protected, not referenced +/// by any kind and not open in a block editor. The pass repeats through the +/// caller's loop (a removed definition frees whatever only its members +/// referenced), so this is one evaluation of the fixed point. +pub fn purge_candidates(document: &CadDocument, open_edits: &[Handle]) -> Vec { + let dynamic = dynamic_block_handles(document); + document + .block_records + .iter() + .filter(|record| { + protected_reason(document, record, &dynamic).is_none() + && !open_edits.contains(&record.handle) + && references_of(document, &record.name).is_empty() + }) + .map(|record| record.name.clone()) + .collect() +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct DeleteOptions<'a> { + /// Erase the referencing INSERTs instead of refusing (the control/"MCP" + /// `"force": true` request). References that are not INSERTs — a + /// dimension-style arrow handle, a multi-leader's block content — are + /// reported but cannot be erased. + pub force: bool, + /// Blocks with an open BEDIT session. Nested sessions all count, so this + /// is the whole session stack, not just the active one. + pub open_edits: &'a [Handle], +} + +#[derive(Clone, Debug)] +pub struct DeleteOutcome { + /// The stored name that was removed. + pub name: String, + pub handle: Handle, + /// Every entity the operation removed: the definition's members, the + /// BLOCK / ENDBLK markers and, on a force delete, the referencing INSERTs. + pub erased: usize, + /// References the delete overrode (empty unless forced). + pub references: Vec, +} + +/// Remove one block definition and its child entities, markers and table +/// record. A referenced block is refused unless `opts.force` is set, in which +/// case the referencing INSERTs are erased in the same (undoable) step. +pub fn delete( + scene: &mut Scene, + name: &str, + opts: DeleteOptions<'_>, +) -> Result { + let record = record_of(&scene.document, name)?; + let dynamic = dynamic_block_handles(&scene.document); + if let Some(reason) = protected_reason(&scene.document, record, &dynamic) { + return Err(BlockServiceError::Protected(reason)); + } + if record.is_anonymous() { + return Err(BlockServiceError::Protected(format!( + "block \"{}\" is an anonymous block", + record.name + ))); + } + if opts.open_edits.contains(&record.handle) { + return Err(BlockServiceError::InEditSession(record.name.clone())); + } + let stored = record.name.clone(); + let handle = record.handle; + let references = references_of(&scene.document, &stored); + if !references.is_empty() && !opts.force { + return Err(BlockServiceError::Referenced { + name: stored, + count: references.len(), + }); + } + let erased = erase(scene, &stored).unwrap_or(0); + Ok(DeleteOutcome { + name: stored, + handle, + erased, + references, + }) +} + +/// Erase a definition the caller has already cleared through +/// [`purge_candidates`]. Returns `Some(entities erased)` when the record was +/// there. This is the one erase every path shares, so a purge, a delete and a +/// force delete leave the same document behind. +pub fn erase(scene: &mut Scene, name: &str) -> Option { + let stored = scene.document.block_records.get(name.trim())?.name.clone(); + let references = references_of(&scene.document, &stored); + Some(erase_definition(scene, &stored, &references)) +} + +/// Erase the definition named `stored`: its members, both markers and the +/// referencing INSERTs in `references`. Returns how many entities went. +fn erase_definition(scene: &mut Scene, stored: &str, references: &[BlockReference]) -> usize { + let Some(record) = scene.document.block_records.get(stored) else { + return 0; + }; + let mut victims: Vec = record.entity_handles.clone(); + victims.push(record.block_entity_handle); + victims.push(record.block_end_handle); + for reference in references { + if reference.kind == BlockReferenceKind::Insert { + victims.push(reference.source); + } + } + victims.sort_unstable(); + victims.dedup(); + let present: Vec = victims + .iter() + .copied() + .filter(|h| scene.document.get_entity(*h).is_some()) + .collect(); + let erased = present.len(); + scene.erase_entities(&present); + // `erase_entities` honours layer locks; a member on a locked layer would + // otherwise dangle against a record that no longer exists. + let survivors: Vec = present + .iter() + .copied() + .filter(|h| scene.document.get_entity(*h).is_some()) + .collect(); + for handle in survivors { + scene.document.remove_entity(handle); + } + scene.document.block_records.remove(stored); + drop_orphaned_draw_order_tables(&mut scene.document); + scene.invalidate_dependency_index(); + scene.bump_geometry(); + erased +} + +/// Drop `SortEntitiesTable` objects whose owning block record is gone (a +/// stale draw-order table points at a block that no longer exists). +fn drop_orphaned_draw_order_tables(document: &mut CadDocument) { + let live: HashSet = document.block_records.iter().map(|r| r.handle).collect(); + let orphans: Vec = document + .objects + .iter() + .filter_map(|(handle, object)| match object { + ObjectType::SortEntitiesTable(table) if !live.contains(&table.block_owner_handle) => { + Some(*handle) + } + _ => None, + }) + .collect(); + for handle in orphans { + document.objects.remove(&handle); + } +} + +/// Rename a block definition: re-key the record, update the BLOCK marker, +/// repoint every INSERT and every name-based style reference (the +/// dimension-style arrow-block names). Handle-based references keep working +/// because the record's handle does not change. +pub fn rename( + scene: &mut Scene, + old: &str, + new: &str, + open_edits: &[Handle], +) -> Result { + let record = record_of(&scene.document, old)?; + let dynamic = dynamic_block_handles(&scene.document); + if let Some(reason) = protected_reason(&scene.document, record, &dynamic) { + return Err(BlockServiceError::Protected(reason)); + } + if record.is_anonymous() { + return Err(BlockServiceError::Protected(format!( + "block \"{}\" is an anonymous block", + record.name + ))); + } + if open_edits.contains(&record.handle) { + return Err(BlockServiceError::InEditSession(record.name.clone())); + } + let new = validate_block_name(new)?; + let stored = record.name.clone(); + if new.eq_ignore_ascii_case(&stored) { + return Err(BlockServiceError::InvalidName( + "the new block name matches the current one (a case-only change is not supported)" + .to_owned(), + )); + } + if scene.document.block_records.get(&new).is_some() { + return Err(BlockServiceError::NameExists(new)); + } + let handle = record.handle; + let marker = record.block_entity_handle; + let mut record = scene + .document + .block_records + .remove(&stored) + .ok_or_else(|| missing(&stored))?; + record.name = new.clone(); + if scene.document.block_records.add(record).is_err() { + return Err(BlockServiceError::NameExists(new)); + } + if let Some(EntityType::Block(block)) = scene.document.get_entity_mut(marker) { + block.name = new.clone(); + } + repoint_name_references(scene, &stored, &new); + scene.invalidate_dependency_index(); + scene.bump_geometry(); + Ok(handle) +} + +/// Every name-based reference follows a rename: INSERTs, a dimension's +/// picture-block name and the dimension styles' arrow-block names. +fn repoint_name_references(scene: &mut Scene, stored: &str, new: &str) { + for entity in scene.document.entities_mut() { + match entity { + EntityType::Insert(insert) if insert.block_name.eq_ignore_ascii_case(stored) => { + insert.block_name = new.to_owned(); + } + EntityType::Dimension(dimension) + if dimension.base().block_name.eq_ignore_ascii_case(stored) => + { + dimension.base_mut().block_name = new.to_owned(); + } + _ => {} + } + } + for style in scene.document.dim_styles.iter_mut() { + for field in [ + &mut style.dimblk_name, + &mut style.dimblk1_name, + &mut style.dimblk2_name, + ] { + if field.eq_ignore_ascii_case(stored) { + *field = new.to_owned(); + } + } + } +} + +fn record_of<'a>( + document: &'a CadDocument, + name: &str, +) -> Result<&'a BlockRecord, BlockServiceError> { + let name = name.trim(); + if name.is_empty() { + return Err(BlockServiceError::InvalidName( + "a block name cannot be empty".to_owned(), + )); + } + document + .block_records + .get(name) + .ok_or_else(|| missing(name)) +} +#[cfg(test)] +mod tests { + use super::*; + use codec::entities::{ + Block as BlockEntity, BlockEnd, Dimension, DimensionAligned, Insert, Line, MultiLeader, + Table, + }; + use codec::objects::{ + DgnLsComponent, DgnLsComponentType, DgnLsDefinition, Dictionary, MultiLeaderStyle, + }; + use codec::tables::{DimStyle, LineType}; + use codec::types::Vector3; + + fn scene() -> Scene { + Scene::new() + } + + /// One block definition with a single LINE member, its BLOCK and ENDBLK + /// markers registered in the table (the codec's `add_entity` files the + /// member under the record's `entity_handles` by owner). + fn define(scene: &mut Scene, name: &str) -> Handle { + let record = scene.document.allocate_handle(); + let marker = scene.document.allocate_handle(); + let end = scene.document.allocate_handle(); + let mut block_record = BlockRecord::new(name); + block_record.handle = record; + block_record.block_entity_handle = marker; + block_record.block_end_handle = end; + scene.document.block_records.add(block_record).unwrap(); + let mut block = BlockEntity::new(name, Vector3::ZERO); + block.common.handle = marker; + block.common.owner_handle = record; + scene.document.add_entity(EntityType::Block(block)).unwrap(); + let mut block_end = BlockEnd::new(); + block_end.common.handle = end; + block_end.common.owner_handle = record; + scene + .document + .add_entity(EntityType::BlockEnd(block_end)) + .unwrap(); + let mut line = Line::from_points(Vector3::ZERO, Vector3::new(1.0, 0.0, 0.0)); + line.common.owner_handle = record; + scene.document.add_entity(EntityType::Line(line)).unwrap(); + record + } + + fn add_insert(scene: &mut Scene, name: &str, owner: Option) { + let mut insert = Insert::new(name.to_string(), Vector3::ZERO); + if let Some(owner) = owner { + insert.common.owner_handle = owner; + } + scene + .document + .add_entity(EntityType::Insert(insert)) + .unwrap(); + } + + fn has_kind(references: &[BlockReference], kind: BlockReferenceKind) -> bool { + references.iter().any(|r| r.kind == kind) + } + + /// The table is keyed by name, so a record handle is checked by scanning. + fn record_exists(scene: &Scene, handle: Handle) -> bool { + scene + .document + .block_records + .iter() + .any(|record| record.handle == handle) + } + + #[test] + fn references_of_reports_every_reference_kind() { + let mut scene = scene(); + let target = define(&mut scene, "SVC_TARGET"); + // An INSERT in model space and one nested inside another definition: + // the flat entity list covers both. + add_insert(&mut scene, "SVC_TARGET", None); + let outer = define(&mut scene, "SVC_OUTER"); + add_insert(&mut scene, "SVC_TARGET", Some(outer)); + + // A dimension's picture block, by name. + let mut dimension = + DimensionAligned::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + dimension.base.block_name = "SVC_TARGET".to_string(); + scene + .document + .add_entity(EntityType::Dimension(Dimension::Aligned(dimension))) + .unwrap(); + + // A table's picture block and a block-valued cell, by handle. + let mut table = Table::new(Vector3::ZERO, 2, 2); + table.block_record_handle = Some(target); + let cell = table.cell_mut(0, 0).unwrap(); + cell.contents + .push(codec::entities::table::CellContent::block(target)); + scene + .document + .add_entity(EntityType::Table(Box::new(table))) + .unwrap(); + + // A multi-leader's block content and arrowhead, by handle. + let mut leader = MultiLeader::new(); + leader.content_type = codec::entities::LeaderContentType::Block; + leader.context.has_block_contents = true; + leader.block_content_handle = Some(target); + leader.arrowhead_handle = Some(target); + scene + .document + .add_entity(EntityType::MultiLeader(Box::new(leader))) + .unwrap(); + + // Document-level styles: a dimension style's arrow block by handle and + // by name, and a multi-leader style's block content. + let mut style = DimStyle::new("SVC_STYLE"); + style.dimblk = target; + style.dimblk1_name = "SVC_TARGET".to_string(); + style.handle = Handle::new(900); + scene.document.dim_styles.add(style).unwrap(); + let mut mleader_style = MultiLeaderStyle::new("SVC_MLSTYLE"); + mleader_style.handle = Handle::new(901); + mleader_style.block_content_handle = Some(target); + mleader_style.arrowhead_handle = Some(target); + scene.document.objects.insert( + mleader_style.handle, + ObjectType::MultiLeaderStyle(mleader_style), + ); + + // A DGN line-style symbol block: the linetype name reaches the + // definition, whose compound component points at the block. + let (definition, root, symbol) = (Handle::new(910), Handle::new(911), Handle::new(912)); + scene.document.dgn_ls_definitions.insert( + definition, + DgnLsDefinition { + handle: definition, + name: "SVC_PIPE".to_string(), + root_component: root, + }, + ); + for (handle, component_type, refs) in [ + (root, DgnLsComponentType::Compound, vec![symbol]), + (symbol, DgnLsComponentType::Symbol, vec![target]), + ] { + scene.document.dgn_ls_components.insert( + handle, + DgnLsComponent { + handle, + component_type, + description: String::new(), + refs, + scale: 1.0, + }, + ); + } + let mut line_type = LineType::new("SVC_PIPE"); + line_type.handle = Handle::new(902); + scene.document.line_types.add(line_type).unwrap(); + + let references = references_of(&scene.document, "SVC_TARGET"); + for kind in [ + BlockReferenceKind::Insert, + BlockReferenceKind::DimensionPicture, + BlockReferenceKind::TablePicture, + BlockReferenceKind::TableCell, + BlockReferenceKind::MultiLeaderContent, + BlockReferenceKind::ArrowHead, + BlockReferenceKind::LineStyleSymbol, + ] { + assert!( + has_kind(&references, kind), + "missing {kind:?} in {references:?}" + ); + } + assert!( + references.iter().all(|r| !r.source.is_null()), + "every reference names its source" + ); + // 2 INSERTs + picture + table picture + cell + multileader content + + // multileader arrow + style arrow by handle + style arrow by name + + // multileader-style content + multileader-style arrow + line symbol + assert_eq!(references.len(), 12); + } + + #[test] + fn rename_follows_every_name_based_reference() { + let mut scene = scene(); + let target = define(&mut scene, "SVC_RENAMED"); + add_insert(&mut scene, "SVC_RENAMED", None); + let mut dimension = + DimensionAligned::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + dimension.base.block_name = "SVC_RENAMED".to_string(); + scene + .document + .add_entity(EntityType::Dimension(Dimension::Aligned(dimension))) + .unwrap(); + let mut style = DimStyle::new("SVC_STYLE"); + style.dimblk = target; + style.dimblk_name = "SVC_RENAMED".to_string(); + scene.document.dim_styles.add(style).unwrap(); + + rename(&mut scene, "SVC_RENAMED", "SVC_RENAMED_NEW", &[]).expect("rename should work"); + + let names: Vec = scene + .document + .entities() + .filter_map(|entity| match entity { + EntityType::Insert(insert) => Some(insert.block_name.clone()), + EntityType::Dimension(dimension) => Some(dimension.base().block_name.clone()), + _ => None, + }) + .collect(); + assert!( + names.iter().all(|name| name == "SVC_RENAMED_NEW"), + "a name-based reference was left behind: {names:?}" + ); + let style = scene.document.dim_styles.get("SVC_STYLE").unwrap(); + assert_eq!(style.dimblk_name, "SVC_RENAMED_NEW"); + assert_eq!( + style.dimblk, target, + "a handle-based reference must survive a rename" + ); + assert!(scene.document.block_records.get("SVC_RENAMED").is_none()); + } + + #[test] + fn delete_refuses_layout_anonymous_xref_and_dynamic_records() { + let mut scene = scene(); + let free = define(&mut scene, "SVC_FREE"); + define(&mut scene, "*SVC_ANON"); + define(&mut scene, "SVC_XREF"); + scene + .document + .block_records + .get_mut("SVC_XREF") + .unwrap() + .flags + .is_xref = true; + define(&mut scene, "*Paper_Space1"); + scene + .document + .block_records + .get_mut("*Paper_Space1") + .unwrap() + .layout = Handle::new(77); + + for (name, expected) in [ + ("SVC_FREE", None), + ( + "*SVC_ANON", + Some("block \"*SVC_ANON\" is an anonymous block"), + ), + ( + "SVC_XREF", + Some("block \"SVC_XREF\" is an externally referenced block"), + ), + ( + "*Paper_Space1", + Some("block \"*Paper_Space1\" is a model or paper space layout record"), + ), + ] { + let error = delete(&mut scene, name, DeleteOptions::default()) + .err() + .map(|e| e.to_string()); + assert_eq!(error.as_deref(), expected, "deleting {name}"); + } + // The free block went on the first pass of the loop above, so a second + // call reports the record as gone. + assert_eq!( + delete(&mut scene, "SVC_FREE", DeleteOptions::default()) + .unwrap_err() + .to_string(), + "block \"SVC_FREE\" does not exist" + ); + assert!(scene.document.block_records.get("SVC_FREE").is_none()); + assert!(scene.document.block_records.get("*SVC_ANON").is_some()); + assert!(scene.document.block_records.get("SVC_XREF").is_some()); + assert!(scene.document.block_records.get("*Paper_Space1").is_some()); + assert!(!record_exists(&scene, free)); + } + + /// A record whose extension dictionary carries an `ACAD_ENHANCEDBLOCK` + /// key, plus the `*U` anonymous record its variants dictionary points at. + fn attach_dynamic_block(scene: &mut Scene, record: &str) -> Handle { + let record_handle = scene.document.block_records.get(record).unwrap().handle; + let variant = define(scene, "*U_VARIANT"); + let xdict = scene.document.allocate_handle(); + let variants = scene.document.allocate_handle(); + let mut dictionary = Dictionary::new(); + dictionary.handle = xdict; + dictionary.owner = record_handle; + dictionary.add_entry("ACAD_ENHANCEDBLOCK", variants); + scene + .document + .objects + .insert(xdict, ObjectType::Dictionary(dictionary)); + let mut variants_dictionary = Dictionary::new(); + variants_dictionary.handle = variants; + variants_dictionary.owner = xdict; + variants_dictionary.add_entry("VisibilityState1", variant); + scene + .document + .objects + .insert(variants, ObjectType::Dictionary(variants_dictionary)); + record_handle + } + + #[test] + fn a_dynamic_block_and_its_u_variants_are_protected() { + let mut scene = scene(); + define(&mut scene, "SVC_DYN"); + let record = attach_dynamic_block(&mut scene, "SVC_DYN"); + let dynamic = dynamic_block_handles(&scene.document); + assert!(dynamic.contains(&record), "the enhanced-block record"); + assert!( + scene + .document + .block_records + .get("*U_VARIANT") + .is_some_and(|r| dynamic.contains(&r.handle)), + "the *U variant sourced by the dictionary" + ); + + assert_eq!( + delete(&mut scene, "SVC_DYN", DeleteOptions::default()) + .unwrap_err() + .to_string(), + "block \"SVC_DYN\" holds dynamic-block data" + ); + assert_eq!( + rename(&mut scene, "SVC_DYN", "SVC_PLAIN", &[]) + .unwrap_err() + .to_string(), + "block \"SVC_DYN\" holds dynamic-block data" + ); + assert!( + !purge_candidates(&scene.document, &[]).contains(&"SVC_DYN".to_string()) + && !purge_candidates(&scene.document, &[]).contains(&"*U_VARIANT".to_string()), + "purge took a dynamic block or its variant" + ); + } + + #[test] + fn purge_candidates_takes_only_unreferenced_unprotected_records() { + let mut scene = scene(); + let free = define(&mut scene, "SVC_PURGE_ME"); + let referenced = define(&mut scene, "SVC_KEEP"); + add_insert(&mut scene, "SVC_KEEP", None); + let anonymous = define(&mut scene, "*SVC_ANON"); + let referenced_anonymous = define(&mut scene, "*SVC_ANON2"); + add_insert(&mut scene, "*SVC_ANON2", None); + + let candidates = purge_candidates(&scene.document, &[]); + assert!(candidates.contains(&"SVC_PURGE_ME".to_string())); + assert!(candidates.contains(&"*SVC_ANON".to_string())); + assert!(!candidates.contains(&"SVC_KEEP".to_string())); + assert!(!candidates.contains(&"*SVC_ANON2".to_string())); + + for name in &candidates { + erase(&mut scene, name).expect("a candidate erases"); + } + assert!(scene.document.block_records.get("SVC_PURGE_ME").is_none()); + assert!(scene.document.block_records.get("*SVC_ANON").is_none()); + assert!(scene.document.block_records.get("SVC_KEEP").is_some()); + assert!(scene.document.block_records.get("*SVC_ANON2").is_some()); + assert!(!record_exists(&scene, free)); + assert!(!record_exists(&scene, anonymous)); + assert!(record_exists(&scene, referenced)); + assert!(record_exists(&scene, referenced_anonymous)); + // A second pass finds nothing, so the caller's loop ends. + assert!(purge_candidates(&scene.document, &[]).is_empty()); + } + + #[test] + fn delete_and_rename_refuse_a_block_open_in_an_edit_session() { + let mut scene = scene(); + let first = define(&mut scene, "SVC_EDIT_ONE"); + let second = define(&mut scene, "SVC_EDIT_TWO"); + let open = [first]; + assert_eq!( + delete( + &mut scene, + "SVC_EDIT_ONE", + DeleteOptions { + open_edits: &open, + ..Default::default() + } + ) + .unwrap_err(), + BlockServiceError::InEditSession("SVC_EDIT_ONE".to_string()) + ); + assert_eq!( + rename(&mut scene, "SVC_EDIT_ONE", "SVC_EDIT_THREE", &open).unwrap_err(), + BlockServiceError::InEditSession("SVC_EDIT_ONE".to_string()) + ); + assert!( + !purge_candidates(&scene.document, &open).contains(&"SVC_EDIT_ONE".to_string()), + "purge skipped the open session" + ); + // A different block is untouched by another block's session. + assert!(delete( + &mut scene, + "SVC_EDIT_TWO", + DeleteOptions { + open_edits: &open, + ..Default::default() + } + ) + .is_ok()); + assert!(scene.document.block_records.get("SVC_EDIT_TWO").is_none()); + assert!(!record_exists(&scene, second)); + } + + #[test] + fn validation_rejects_reserved_characters_and_matches_case_insensitively() { + assert_eq!( + validate_block_name(" "), + Err(BlockServiceError::InvalidName( + "a block name cannot be empty".to_string() + )) + ); + for bad in ["bad/name", "a*b", "left|right", "tag=1", "q`uote"] { + assert!( + matches!(validate_block_name(bad), Err(BlockServiceError::InvalidName(message)) if message.contains("cannot contain")), + "{bad} was accepted" + ); + } + assert_eq!( + validate_block_name(" SVC_TRIM "), + Ok("SVC_TRIM".to_string()) + ); + + let mut scene = scene(); + define(&mut scene, "SVC_LOWER"); + define(&mut scene, "SVC_EXISTING"); + // The table matches case-insensitively, so an upper-case rename target + // collides with a lower-case record. + assert_eq!( + rename(&mut scene, "svc_lower", "SVC_EXISTING", &[]).unwrap_err(), + BlockServiceError::NameExists("SVC_EXISTING".to_string()) + ); + } + + #[test] + fn a_forced_delete_erases_the_referencing_inserts() { + let mut scene = scene(); + define(&mut scene, "SVC_FORCED"); + add_insert(&mut scene, "SVC_FORCED", None); + let outer = define(&mut scene, "SVC_FORCED_WRAPPER"); + add_insert(&mut scene, "SVC_FORCED", Some(outer)); + let outcome = delete( + &mut scene, + "SVC_FORCED", + DeleteOptions { + force: true, + open_edits: &[], + }, + ) + .expect("a forced delete erases the references"); + assert_eq!(outcome.references.len(), 2); + // 2 INSERTs, the member LINE and both markers. + assert_eq!(outcome.erased, 5); + assert!(scene.document.block_records.get("SVC_FORCED").is_none()); + assert_eq!( + scene + .document + .entities() + .filter(|entity| matches!(entity, EntityType::Insert(insert) if insert.block_name == "SVC_FORCED")) + .count(), + 0 + ); + assert!(scene + .document + .block_records + .get("SVC_FORCED_WRAPPER") + .is_some()); + } + + #[test] + fn the_formatter_words_every_error_the_same_way() { + assert_eq!( + BlockServiceError::NotFound("A".to_string()).to_string(), + "block \"A\" does not exist" + ); + assert_eq!( + BlockServiceError::Referenced { + name: "A".to_string(), + count: 2 + } + .to_string(), + "block \"A\" is referenced 2 time(s) in the drawing and cannot be removed" + ); + assert_eq!( + BlockServiceError::NameExists("A".to_string()).to_string(), + "block \"A\" already exists" + ); + assert_eq!( + BlockServiceError::InEditSession("A".to_string()).to_string(), + "block \"A\" is open in the block editor; close the editor first" + ); + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 6a10a9e33..978a780b6 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -6,6 +6,7 @@ // view — camera, transforms, viewport, render pipeline driver // cache — block-definition and property caches pub mod annotative; +pub mod block_service; pub mod cache; pub mod convert; pub mod creation_style; diff --git a/src/scene/render_graph.rs b/src/scene/render_graph.rs index 38ee100e6..9b9dc8d46 100644 --- a/src/scene/render_graph.rs +++ b/src/scene/render_graph.rs @@ -1027,44 +1027,6 @@ pub fn entity_block_uses( uses } -/// Blocks referenced by document-level styles rather than a concrete entity. -pub fn document_block_uses(document: &CadDocument) -> Vec { - let mut uses = Vec::new(); - for style in document.dim_styles.iter() { - for handle in [style.dimblk, style.dimblk1, style.dimblk2, style.dimldrblk] { - push_dependency_use(&mut uses, document, handle, BlockRole::ArrowHead); - } - for name in [&style.dimblk_name, &style.dimblk1_name, &style.dimblk2_name] { - if let Some(record) = block_record_by_name(document, name) { - push_dependency_use(&mut uses, document, record.handle, BlockRole::ArrowHead); - } - } - } - for object in document.objects.values() { - let codec::objects::ObjectType::MultiLeaderStyle(style) = object else { - continue; - }; - if let Some(handle) = style.arrowhead_handle { - push_dependency_use(&mut uses, document, handle, BlockRole::ArrowHead); - } - if let Some(handle) = style.block_content_handle { - push_dependency_use(&mut uses, document, handle, BlockRole::MultiLeaderContent); - } - } - for line_type in document.line_types.iter() { - for symbol in crate::scene::convert::dgn_linestyle::symbol_blocks(document, &line_type.name) - { - push_dependency_use( - &mut uses, - document, - symbol.block, - BlockRole::LineStyleSymbol, - ); - } - } - uses -} - const MAX_MINSERT_INSTANCES: usize = 20_000; pub fn array_offsets(insert: &Insert) -> Vec<[f64; 3]> {