forked from 1b-agent/opencadstudio-upstream
fix(blocks): one block definition service for delete, rename and purge on every path
new src/scene/block_service.rs (references, delete, rename, purge candidates, name validation, one error type and message). The command-line RENAME, palette Remove, plugin API, control/MCP block_delete and PURGE all use it. Layout, xref, dynamic-block, anonymous and open-in-editor records are protected; PURGE removes an anonymous block only when nothing references it. PROTOCOL CHANGE: control/MCP block_delete refuses a referenced block unless force=true (erases referencing INSERTs in one undo step). New REST route DELETE /api/v1/blocks/{name}?force=true. MCP tool digest re-pinned. Undo labels are Delete block X, Rename block X to Y and Purge.
This commit is contained in:
1 parent
5dfa41c032
commit
2b50b6c858
16 files changed
+2701
-262
No files matched your search
@@ -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":"<handle>"}`. 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 <name>`). 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":"<file>"` (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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
+54
-107
@@ -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<String> = {
|
||||
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<String> {
|
||||
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<String> = 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<String> = 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<String> = if do_blocks {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
let open_edits: Vec<codec::Handle> = 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<String> = None;
|
||||
let ok = if block {
|
||||
let open_edits: Vec<codec::Handle> = 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.",
|
||||
|
||||
+42
-23
@@ -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<Task<Message>, 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<codec::Handle> = 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<Option<Vec<codec::xdata::XDataValue>>> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
+61
-64
@@ -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<Handle> = {
|
||||
let record = doc.block_records.get(&name).expect("checked above");
|
||||
let mut members = record.entity_handles.clone();
|
||||
members.push(record.block_entity_handle);
|
||||
members.push(record.block_end_handle);
|
||||
members
|
||||
};
|
||||
self.push_undo("Delete block");
|
||||
let doc = self.document_mut();
|
||||
for member in members {
|
||||
doc.remove_entity(member);
|
||||
}
|
||||
doc.block_records.remove(&name);
|
||||
let orphans: Vec<Handle> = {
|
||||
let live: std::collections::HashSet<Handle> =
|
||||
doc.block_records.iter().map(|r| r.handle).collect();
|
||||
doc.objects
|
||||
.iter()
|
||||
.filter_map(|(h, object)| match object {
|
||||
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<Handle> {
|
||||
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();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<codec::Handle> = 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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+3
-1
@@ -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;
|
||||
|
||||
+10
-3
@@ -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 {
|
||||
|
||||
+24
-5
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
@@ -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<BlockUse> {
|
||||
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]> {
|
||||
|
||||
Reference in new issue
Block a user