forked from 1b-agent/opencadstudio-upstream
The codec now holds wide EntityType variants boxed (MText, Helix, Dimension, Hatch, Viewport, MultiLeader, Surface, Table, Ole2Frame, Light, Extended). Constructors box their entity; nested patterns reach a dimension through entities::dimension::as_dimension(_mut). Also follows BlockAction::action_ids, LineTypeComplexData::source_flags and the decimal tab stop with an explicit character. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1293 lines
48 KiB
Rust
1293 lines
48 KiB
Rust
//! 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, TableEntry};
|
|
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<BlockReference> {
|
|
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<BlockReference> {
|
|
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<BlockReference>,
|
|
) {
|
|
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<String, BlockServiceError> {
|
|
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<Handle>,
|
|
) -> Option<String> {
|
|
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(_))
|
|
)
|
|
}
|
|
|
|
/// Candidate block names for the Edit Block Definition (BEDIT) dialog:
|
|
/// excludes standard layouts (*MODEL_SPACE, *PAPER_SPACE), layout records,
|
|
/// anonymous blocks (*U, *D, *T, etc.), and externally referenced blocks.
|
|
/// Sorted alphabetically, case-insensitive.
|
|
pub fn bedit_candidate_block_names(document: &CadDocument) -> Vec<String> {
|
|
let mut names: Vec<String> = document
|
|
.block_records
|
|
.iter()
|
|
.filter(|record| {
|
|
!record.is_standard()
|
|
&& !is_layout_record(document, record)
|
|
&& !record.is_anonymous()
|
|
&& !record.name.starts_with('*')
|
|
&& !record.flags.is_xref
|
|
&& !record.flags.is_xref_overlay
|
|
&& !record.name.contains('|')
|
|
})
|
|
.map(|record| record.name.clone())
|
|
.collect();
|
|
names.sort_by_key(|n| n.to_ascii_lowercase());
|
|
names
|
|
}
|
|
|
|
/// 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<Handle> {
|
|
let mut protected: HashSet<Handle> = 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<Handle> = 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<Handle>) {
|
|
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<String> {
|
|
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<BlockReference>,
|
|
}
|
|
|
|
/// 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<DeleteOutcome, BlockServiceError> {
|
|
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<usize> {
|
|
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<Handle> = 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<Handle> = 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<Handle> = 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<Handle> = document.block_records.iter().map(|r| r.handle).collect();
|
|
let orphans: Vec<Handle> = 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<Handle, BlockServiceError> {
|
|
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))
|
|
}
|
|
|
|
/// Checks whether inserting `inserted_block` into `container_block` would create a circular
|
|
/// block nesting dependency, directly or transitively.
|
|
///
|
|
/// Matching is case-insensitive, consistent with the rest of the block service.
|
|
///
|
|
/// Xrefs (`|` in name or xref flags) and anonymous blocks (`*` prefix) are handled as follows:
|
|
/// - Anonymous blocks (`*U...`) are fully traversed like normal blocks because their geometry
|
|
/// and nested inserts live in the document.
|
|
/// - Externally referenced blocks (xrefs) have definitions that come from external files and
|
|
/// are not edited inline in the host document, but if an xref name is tested, it is matched
|
|
/// case-insensitively and its local block record entities (if any) are traversed.
|
|
pub fn would_create_cycle(
|
|
document: &CadDocument,
|
|
container_block: &str,
|
|
inserted_block: &str,
|
|
) -> bool {
|
|
let container = container_block.trim();
|
|
let inserted = inserted_block.trim();
|
|
if container.is_empty() || inserted.is_empty() {
|
|
return false;
|
|
}
|
|
if container.eq_ignore_ascii_case(inserted) {
|
|
return true;
|
|
}
|
|
|
|
let mut visited: HashSet<String> = HashSet::default();
|
|
let mut stack: Vec<String> = vec![inserted.to_ascii_uppercase()];
|
|
|
|
while let Some(current_upper) = stack.pop() {
|
|
if !visited.insert(current_upper.clone()) {
|
|
continue;
|
|
}
|
|
|
|
// Find block record matching current_upper case-insensitively
|
|
let Some(record) = document.block_records.get(¤t_upper) else {
|
|
continue;
|
|
};
|
|
|
|
// Check entities owned by this block record: either registered in entity_handles
|
|
// or having owner_handle == record.handle in the document.
|
|
let mut sub_inserts = Vec::new();
|
|
for &handle in &record.entity_handles {
|
|
if let Some(EntityType::Insert(sub_ins)) = document.get_entity(handle) {
|
|
sub_inserts.push(sub_ins.block_name.trim().to_string());
|
|
}
|
|
}
|
|
for entity in document.entities() {
|
|
if entity.common().owner_handle == record.handle {
|
|
if let EntityType::Insert(sub_ins) = entity {
|
|
sub_inserts.push(sub_ins.block_name.trim().to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
for sub_name in sub_inserts {
|
|
if sub_name.eq_ignore_ascii_case(container) {
|
|
return true;
|
|
}
|
|
let sub_upper = sub_name.to_ascii_uppercase();
|
|
if !visited.contains(&sub_upper) {
|
|
stack.push(sub_upper);
|
|
}
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
pub const CIRCULAR_BLOCK_DEPENDENCY_ERROR: &str =
|
|
"Cannot insert block: circular dependency detected.";
|
|
|
|
/// Standard command-line error text when block insertion would create a circular dependency.
|
|
pub fn circular_dependency_error(container_block: &str, inserted_block: &str) -> String {
|
|
format!(
|
|
"Cannot insert block \"{}\" into block \"{}\": circular dependency detected.",
|
|
inserted_block, container_block
|
|
)
|
|
}
|
|
|
|
#[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<Handle>) {
|
|
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(Box::new(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(Box::new(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<String> = 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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn locked_would_create_cycle_self_insert() {
|
|
let mut scene = scene();
|
|
define(&mut scene, "BLK_A");
|
|
assert!(would_create_cycle(&scene.document, "BLK_A", "BLK_A"));
|
|
assert!(would_create_cycle(&scene.document, "blk_a", "BLK_A"));
|
|
assert!(would_create_cycle(&scene.document, "BLK_A", "blk_a"));
|
|
}
|
|
|
|
#[test]
|
|
fn locked_would_create_cycle_direct_pair() {
|
|
let mut scene = scene();
|
|
let blk_a = define(&mut scene, "BLK_A");
|
|
define(&mut scene, "BLK_B");
|
|
// A contains B
|
|
add_insert(&mut scene, "BLK_B", Some(blk_a));
|
|
// Inserting A into B would create a cycle
|
|
assert!(would_create_cycle(&scene.document, "BLK_B", "BLK_A"));
|
|
// Inserting B into A is not allowed either since A already inserts B
|
|
assert!(!would_create_cycle(&scene.document, "BLK_A", "BLK_B"));
|
|
}
|
|
|
|
#[test]
|
|
fn locked_would_create_cycle_three_level_chain() {
|
|
let mut scene = scene();
|
|
let blk_a = define(&mut scene, "BLK_A");
|
|
let blk_b = define(&mut scene, "BLK_B");
|
|
define(&mut scene, "BLK_C");
|
|
// A contains B, B contains C
|
|
add_insert(&mut scene, "BLK_B", Some(blk_a));
|
|
add_insert(&mut scene, "BLK_C", Some(blk_b));
|
|
// Inserting A into C creates cycle C -> A -> B -> C
|
|
assert!(would_create_cycle(&scene.document, "BLK_C", "BLK_A"));
|
|
// Inserting B into C creates cycle C -> B -> C
|
|
assert!(would_create_cycle(&scene.document, "BLK_C", "BLK_B"));
|
|
}
|
|
|
|
#[test]
|
|
fn locked_would_create_cycle_shared_child_not_a_cycle() {
|
|
let mut scene = scene();
|
|
let blk_a = define(&mut scene, "BLK_A");
|
|
let blk_b = define(&mut scene, "BLK_B");
|
|
define(&mut scene, "BLK_SHARED");
|
|
// A contains SHARED, B contains SHARED. Inserting B into A is fine (diamond/DAG, not cycle).
|
|
add_insert(&mut scene, "BLK_SHARED", Some(blk_a));
|
|
add_insert(&mut scene, "BLK_SHARED", Some(blk_b));
|
|
assert!(!would_create_cycle(&scene.document, "BLK_A", "BLK_B"));
|
|
assert!(!would_create_cycle(&scene.document, "BLK_B", "BLK_A"));
|
|
}
|
|
|
|
#[test]
|
|
fn locked_would_create_cycle_case_differences() {
|
|
let mut scene = scene();
|
|
let blk_a = define(&mut scene, "BlockA");
|
|
let _blk_b = define(&mut scene, "BlockB");
|
|
add_insert(&mut scene, "BlockB", Some(blk_a));
|
|
assert!(would_create_cycle(&scene.document, "blockb", "BLOCKA"));
|
|
assert!(!would_create_cycle(&scene.document, "blocka", "BLOCKB"));
|
|
}
|
|
|
|
#[test]
|
|
fn locked_bedit_candidate_block_names_filters_and_sorts() {
|
|
let mut scene = scene();
|
|
define(&mut scene, "zebra");
|
|
define(&mut scene, "Alpha");
|
|
define(&mut scene, "Beta");
|
|
|
|
// Anonymous block
|
|
let mut anon = BlockRecord::new("*U1");
|
|
anon.handle = Handle::new(9901);
|
|
anon.flags.anonymous = true;
|
|
scene.document.block_records.add(anon).unwrap();
|
|
|
|
// Xref block
|
|
let mut xref = BlockRecord::new("XrefBlock");
|
|
xref.handle = Handle::new(9902);
|
|
xref.flags.is_xref = true;
|
|
scene.document.block_records.add(xref).unwrap();
|
|
|
|
// Xref dependent symbol
|
|
let mut xref_dep = BlockRecord::new("HOST|CHILD");
|
|
xref_dep.handle = Handle::new(9903);
|
|
scene.document.block_records.add(xref_dep).unwrap();
|
|
|
|
let candidates = bedit_candidate_block_names(&scene.document);
|
|
assert_eq!(candidates, vec!["Alpha", "Beta", "zebra"]);
|
|
}
|
|
}
|