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 |
|
| 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_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}`. |
|
| `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
|
### 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
|
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_define` never fails with "already exists" on a re-import.
|
||||||
`block_delete` removes a definition completely (children, Block/BlockEnd
|
`block_delete` removes a definition completely (children, Block/BlockEnd
|
||||||
markers, every Insert referencing it and the table record) and reports
|
markers and the table record). A definition anything still points at — an
|
||||||
`result.erased`; unknown names are `block_missing`, `*`-prefixed layout
|
INSERT, a dimension-style arrow block, a multi-leader's block content — is
|
||||||
records are `block_protected`. Definitions survive save/open round trips
|
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
|
and are consumable by `wblock` (by block name) and `INSERT` creation via
|
||||||
`entities_create`.
|
`entities_create`.
|
||||||
|
|
||||||
|
|||||||
+54
-107
@@ -358,62 +358,6 @@ impl OpenCADStudio {
|
|||||||
.map(|s| s.to_uppercase())
|
.map(|s| s.to_uppercase())
|
||||||
.collect();
|
.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)
|
// Build removal lists (still immutable)
|
||||||
let layer_remove: Vec<String> = if do_layers {
|
let layer_remove: Vec<String> = if do_layers {
|
||||||
self.tabs[i]
|
self.tabs[i]
|
||||||
@@ -466,26 +410,21 @@ impl OpenCADStudio {
|
|||||||
vec![]
|
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 {
|
let block_remove: Vec<String> = if do_blocks {
|
||||||
self.tabs[i]
|
let open_edits: Vec<codec::Handle> = self.tabs[i]
|
||||||
.scene
|
.block_edits
|
||||||
.document
|
|
||||||
.block_records
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|br| {
|
.map(|session| session.br_handle)
|
||||||
let up = br.name.to_ascii_uppercase();
|
.collect();
|
||||||
// Never PURGE an xref *definition* — that is a
|
crate::scene::block_service::purge_candidates(
|
||||||
// detach, not a purge. Orphaned dependencies from
|
&self.tabs[i].scene.document,
|
||||||
// a past detach still use the reachability test.
|
&open_edits,
|
||||||
!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()
|
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
vec![]
|
||||||
};
|
};
|
||||||
@@ -501,7 +440,7 @@ impl OpenCADStudio {
|
|||||||
// Snapshot the pre-purge document once, before the first
|
// Snapshot the pre-purge document once, before the first
|
||||||
// removal, so a single UNDO restores everything.
|
// removal, so a single UNDO restores everything.
|
||||||
if !snapshot_pushed {
|
if !snapshot_pushed {
|
||||||
self.push_undo_snapshot(i, "PURGE");
|
self.push_undo_snapshot(i, "Purge");
|
||||||
snapshot_pushed = true;
|
snapshot_pushed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,26 +462,11 @@ impl OpenCADStudio {
|
|||||||
.count();
|
.count();
|
||||||
let mut blocks_gone = 0usize;
|
let mut blocks_gone = 0usize;
|
||||||
for name in &block_remove {
|
for name in &block_remove {
|
||||||
// Drop the block definition's member entities (and the
|
// The service erases the definition, its member
|
||||||
// BLOCK/ENDBLK delimiters) before the record so no orphaned
|
// entities, both BLOCK/ENDBLK delimiters and any stale
|
||||||
// geometry survives in the document entity list.
|
// draw-order table, so a purge and a delete leave the
|
||||||
let handles: Vec<_> =
|
// same document behind.
|
||||||
if let Some(br) = self.tabs[i].scene.document.block_records.get(name) {
|
if crate::scene::block_service::erase(&mut self.tabs[i].scene, 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)
|
|
||||||
.is_some()
|
.is_some()
|
||||||
{
|
{
|
||||||
blocks_gone += 1;
|
blocks_gone += 1;
|
||||||
@@ -586,7 +510,7 @@ impl OpenCADStudio {
|
|||||||
// re-assignment needed (a block purge already snapshotted;
|
// re-assignment needed (a block purge already snapshotted;
|
||||||
// a snapshot here only matters when nothing else changed).
|
// a snapshot here only matters when nothing else changed).
|
||||||
if !snapshot_pushed {
|
if !snapshot_pushed {
|
||||||
self.push_undo_snapshot(i, "PURGE");
|
self.push_undo_snapshot(i, "Purge");
|
||||||
}
|
}
|
||||||
for h in &orphans {
|
for h in &orphans {
|
||||||
self.tabs[i].scene.document.objects.remove(h);
|
self.tabs[i].scene.document.objects.remove(h);
|
||||||
@@ -3392,7 +3316,13 @@ impl OpenCADStudio {
|
|||||||
} else {
|
} else {
|
||||||
// Snapshot BEFORE mutating (undo must restore the old
|
// Snapshot BEFORE mutating (undo must restore the old
|
||||||
// name); dropped again if nothing was renamed.
|
// 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!(
|
let known = matches!(
|
||||||
type_str.as_str(),
|
type_str.as_str(),
|
||||||
"LAYER"
|
"LAYER"
|
||||||
@@ -3405,11 +3335,31 @@ impl OpenCADStudio {
|
|||||||
| "UCS"
|
| "UCS"
|
||||||
| "VIEW"
|
| "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
|
// Re-keys the record, syncs the Block marker and every
|
||||||
// INSERT reference; refuses anonymous/xref sources and
|
// INSERT reference; refuses anonymous/xref sources,
|
||||||
// invalid/taken names.
|
// protected records and invalid/taken names.
|
||||||
self.tabs[i].scene.rename_block(&old_name, &new_name)
|
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" {
|
} else if type_str == "LAYER" {
|
||||||
self.tabs[i].rename_layer(&old_name, &new_name)
|
self.tabs[i].rename_layer(&old_name, &new_name)
|
||||||
} else if known {
|
} else if known {
|
||||||
@@ -3444,13 +3394,10 @@ impl OpenCADStudio {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
self.discard_last_undo_entry(i);
|
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());
|
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 {
|
} else {
|
||||||
self.command_line.push_error(crate::tf!(
|
self.command_line.push_error(crate::tf!(
|
||||||
"RENAME: could not rename '{}' in {} — not found or protected, or '{}' is invalid or taken.",
|
"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())
|
Ok(Task::none())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `block_delete` — remove a block definition together with its child
|
/// `block_delete` — remove a block definition. A definition something
|
||||||
/// entities, the Block/BlockEnd markers and every Insert referencing it.
|
/// 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> {
|
pub(super) fn control_block_delete(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
||||||
let name = string(req, "name")?.to_owned();
|
let name = string(req, "name")?.to_owned();
|
||||||
if name.starts_with('*') {
|
let force = req["force"].as_bool().unwrap_or(false);
|
||||||
return Err(failure(
|
|
||||||
"block_protected",
|
|
||||||
"Layout block records cannot be deleted",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
if self.tabs[i]
|
let open_edits: Vec<codec::Handle> = self.tabs[i]
|
||||||
.scene
|
.block_edits
|
||||||
.document
|
.iter()
|
||||||
.block_records
|
.map(|session| session.br_handle)
|
||||||
.get(&name)
|
.collect();
|
||||||
.is_none()
|
self.push_undo_snapshot(i, format!("Delete block {name}"));
|
||||||
{
|
let options = crate::scene::block_service::DeleteOptions {
|
||||||
return Err(failure(
|
force,
|
||||||
"block_missing",
|
open_edits: &open_edits,
|
||||||
format!("Block '{name}' does not exist"),
|
};
|
||||||
));
|
let outcome =
|
||||||
}
|
match crate::scene::block_service::delete(&mut self.tabs[i].scene, &name, options) {
|
||||||
self.push_undo_snapshot(i, "BLOCKDELETE");
|
Ok(outcome) => outcome,
|
||||||
let erased = erase_block_definition(&mut self.tabs[i].scene, &name);
|
Err(error) => {
|
||||||
|
self.discard_last_undo_entry(i);
|
||||||
|
return Err(failure(block_error_code(&error), error.to_string()));
|
||||||
|
}
|
||||||
|
};
|
||||||
self.post_ref_op(i);
|
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())
|
Ok(Task::none())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,6 +992,21 @@ pub(crate) fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str
|
|||||||
victims.len()
|
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
|
/// `data:[{code,value},…]` → typed XData values; an absent or empty list
|
||||||
/// means "remove this application's record".
|
/// means "remove this application's record".
|
||||||
fn parse_xdata_values(req: &Value) -> Parsed<Option<Vec<codec::xdata::XDataValue>>> {
|
fn parse_xdata_values(req: &Value) -> Parsed<Option<Vec<codec::xdata::XDataValue>>> {
|
||||||
|
|||||||
@@ -1631,7 +1631,6 @@ mod actions;
|
|||||||
pub(crate) mod vision;
|
pub(crate) mod vision;
|
||||||
pub(crate) use actions::property_json;
|
pub(crate) use actions::property_json;
|
||||||
mod entities;
|
mod entities;
|
||||||
pub(crate) use entities::erase_block_definition;
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub(crate) mod http_bridge;
|
pub(crate) mod http_bridge;
|
||||||
mod interactive;
|
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, "Line"), 0, "old children erased");
|
||||||
assert_eq!(count_type(&mut app, "Circle"), 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.
|
// Delete removes the insert, the markers, the children and the record.
|
||||||
let r = app.automation_op(&format!(
|
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["ok"], true, "{}", r["error"]);
|
||||||
|
assert_eq!(
|
||||||
|
r["result"]["references"].as_u64().unwrap(),
|
||||||
|
1,
|
||||||
|
"the forced delete reports what it overrode"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
r["result"]["erased"].as_u64().unwrap() >= 3,
|
r["result"]["erased"].as_u64().unwrap() >= 3,
|
||||||
"insert + markers + child"
|
"insert + markers + child"
|
||||||
|
|||||||
+61
-64
@@ -2337,80 +2337,77 @@ impl<'a> HostSession<'a> {
|
|||||||
Ok(handle)
|
Ok(handle)
|
||||||
}
|
}
|
||||||
TableOperation::BlockRename { from, to } => {
|
TableOperation::BlockRename { from, to } => {
|
||||||
let (from, handle) = editable_block(self.document(), from.trim())?;
|
let from = from.trim().to_owned();
|
||||||
let to = validated_symbol_name(&to, "a block name")?;
|
let to = to.trim().to_owned();
|
||||||
if to.starts_with('*') {
|
let open_edits = self.open_block_edits();
|
||||||
return Err("a block name cannot start with '*'".to_owned());
|
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 } => {
|
TableOperation::BlockDelete { name } => {
|
||||||
let (name, handle) = editable_block(self.document(), name.trim())?;
|
let name = name.trim().to_owned();
|
||||||
let doc = self.document();
|
// The external API has no force flag, so a referenced block is
|
||||||
let referenced = doc.entities().any(|entity| match entity {
|
// always refused here — same rule as the palette.
|
||||||
E::Insert(insert) => insert.block_name.eq_ignore_ascii_case(&name),
|
let open_edits = self.open_block_edits();
|
||||||
E::MultiLeader(leader) => {
|
let handle = self
|
||||||
leader.block_content_handle == Some(handle)
|
.document()
|
||||||
|| leader.context.block_content_handle == Some(handle)
|
.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()),
|
_ => 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) {
|
fn finish_block_change(&mut self) {
|
||||||
self.app.tabs[self.tab].dirty = true;
|
self.app.tabs[self.tab].dirty = true;
|
||||||
self.app.tabs[self.tab].scene.invalidate_dependency_index();
|
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 => {
|
Tab::Current => {
|
||||||
let name = item.name();
|
let name = item.name();
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
let referenced = self.tabs[i].scene.document.entities().any(|e| {
|
let open_edits: Vec<codec::Handle> = self.tabs[i]
|
||||||
matches!(e, codec::EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(&name))
|
.block_edits
|
||||||
});
|
.iter()
|
||||||
if referenced {
|
.map(|session| session.br_handle)
|
||||||
self.command_line.push_error(&crate::tf!(
|
.collect();
|
||||||
"Block \"{name}\" is referenced in the drawing and cannot be removed."
|
self.push_undo_snapshot(i, format!("Delete block {name}"));
|
||||||
));
|
let outcome = crate::scene::block_service::delete(
|
||||||
} else {
|
&mut self.tabs[i].scene,
|
||||||
self.push_undo_snapshot(i, "PURGE");
|
&name,
|
||||||
crate::app::control::erase_block_definition(&mut self.tabs[i].scene, &name);
|
crate::scene::block_service::DeleteOptions {
|
||||||
self.tabs[i].dirty = true;
|
force: false,
|
||||||
self.command_line
|
open_edits: &open_edits,
|
||||||
.push_output(&crate::tf!("Block \"{name}\" removed."));
|
},
|
||||||
self.refresh_block_palette();
|
);
|
||||||
|
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 => {
|
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)
|
(insertion != from).then_some(insertion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod block_ops_tests;
|
||||||
mod blocks_palette;
|
mod blocks_palette;
|
||||||
mod command;
|
mod command;
|
||||||
mod context_menu;
|
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
|
/// 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,
|
/// changes, so schema drift (new params, renamed tools) is always a conscious,
|
||||||
/// reviewed edit — and clients can detect a stale bridge by comparing digests.
|
/// reviewed edit — and clients can detect a stale bridge by comparing digests.
|
||||||
|
///
|
||||||
|
/// `block_delete` gained its optional `force` parameter.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
const TOOL_SCHEMA_DIGEST: &str = "e7bd01696f57143d7139aeb75d53389450b1102b603f2ba4ace8122cc692070e";
|
const TOOL_SCHEMA_DIGEST: &str = "c16572f139198d90807ede156370879106672ed97566c16ab6ae001c996059e8";
|
||||||
const MAX_REQUEST: usize = 1_048_576;
|
const MAX_REQUEST: usize = 1_048_576;
|
||||||
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
|
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
|
||||||
const CACHE_TTL_MS: u64 = 3_600_000;
|
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 {
|
pub static BLOCK_DELETE: OpDef = OpDef {
|
||||||
name: "block_delete",
|
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,
|
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: &[],
|
rules: &[],
|
||||||
example: r#"{"op":"block_delete","name":"DOOR"}"#,
|
example: r#"{"op":"block_delete","name":"DOOR","force":true}"#,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub static XDATA_SET: OpDef = OpDef {
|
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,
|
fields: body,
|
||||||
created: 201,
|
created: 201,
|
||||||
},
|
},
|
||||||
("DELETE", ["blocks", name]) => Plan::Mutate {
|
("DELETE", ["blocks", name]) => {
|
||||||
op: "block_delete".into(),
|
// `?force=true` mirrors the control op: it erases the referencing
|
||||||
fields: json!({"name": name}),
|
// inserts instead of refusing a referenced definition.
|
||||||
created: 200,
|
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 {
|
("POST", ["file-identity"]) => Plan::Mutate {
|
||||||
op: "file_identity".into(),
|
op: "file_identity".into(),
|
||||||
fields: body,
|
fields: body,
|
||||||
@@ -1440,13 +1451,21 @@ mod tests {
|
|||||||
&mut counter,
|
&mut counter,
|
||||||
);
|
);
|
||||||
assert_eq!(status, 201, "{body}");
|
assert_eq!(status, 201, "{body}");
|
||||||
|
// A definition the insert still points at is refused without force.
|
||||||
let (status, body) = route(
|
let (status, body) = route(
|
||||||
&mut app,
|
&mut app,
|
||||||
&request("DELETE", "/api/v1/blocks/MARK", ""),
|
&request("DELETE", "/api/v1/blocks/MARK", ""),
|
||||||
&mut document_id,
|
&mut document_id,
|
||||||
&mut counter,
|
&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!(status, 200, "{body}");
|
||||||
|
assert_eq!(body["result"]["references"], 1, "{body}");
|
||||||
assert!(body["result"]["erased"].as_u64().unwrap() >= 1);
|
assert!(body["result"]["erased"].as_u64().unwrap() >= 1);
|
||||||
let (status, body) = route(
|
let (status, body) = route(
|
||||||
&mut app,
|
&mut app,
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -6,6 +6,7 @@
|
|||||||
// view — camera, transforms, viewport, render pipeline driver
|
// view — camera, transforms, viewport, render pipeline driver
|
||||||
// cache — block-definition and property caches
|
// cache — block-definition and property caches
|
||||||
pub mod annotative;
|
pub mod annotative;
|
||||||
|
pub mod block_service;
|
||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod convert;
|
pub mod convert;
|
||||||
pub mod creation_style;
|
pub mod creation_style;
|
||||||
|
|||||||
@@ -1027,44 +1027,6 @@ pub fn entity_block_uses(
|
|||||||
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;
|
const MAX_MINSERT_INSTANCES: usize = 20_000;
|
||||||
|
|
||||||
pub fn array_offsets(insert: &Insert) -> Vec<[f64; 3]> {
|
pub fn array_offsets(insert: &Insert) -> Vec<[f64; 3]> {
|
||||||
|
|||||||
Reference in new issue
Block a user