Files
OneCAD-Draft/src/io/xref.rs
T
ramox81andClaude Opus 5.5 cb7860e50e DWF and DGN underlays: attach, display, snaps, frames and references
DWFATTACH / -DWFATTACH and DGNATTACH / -DGNATTACH attach a DWF / DWFx sheet
or a DGN (V7 or V8) model with the reference's prompts; ATTACH routes the
file types to them. The sheet or model is read into vector paths, drawn as a
transparent underlay raster (contrast, fade, monochrome and background
adjustment apply) and offers its geometry to object snaps. Definitions are
kept in ACAD_DWFDEFINITIONS / ACAD_DGNDEFINITIONS, the External References
palette lists and reloads them, and image textures get line-keeping mip
levels so thin underlay lines stay visible when zoomed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:53:25 +03:00

3109 lines
123 KiB
Rust

// XREF resolution — scan a loaded document for external-reference blocks and
// populate them with geometry from the referenced DWG/DXF files.
use codec::entities::{Block, BlockEnd};
use codec::tables::TableEntry;
use codec::types::{Handle, Vector3};
use codec::{CadDocument, EntityType};
use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
use std::path::{Path, PathBuf};
#[cfg(not(target_arch = "wasm32"))]
type SourceFingerprint = crate::io::edit_lock::FileFingerprint;
#[cfg(target_arch = "wasm32")]
type SourceFingerprint = ();
/// Status of an external reference block.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum XrefStatus {
/// File was found and loaded successfully.
Loaded,
/// File was loaded with recoverable parser errors.
Recovered,
/// File was found but could not produce usable drawing data.
Failed,
/// File path is set but the file could not be found or read.
NotFound,
/// XRef is marked Unloaded in the host DWG — we honor that and
/// skip resolving the external file. The user can re-load via UI.
#[allow(dead_code)]
Unloaded,
}
/// Describes a single external reference found in a document.
#[derive(Debug, Clone)]
pub struct XrefInfo {
/// Block name (e.g. the filename stem).
pub name: String,
/// Resolved file path (or raw path if not found).
pub path: String,
pub status: XrefStatus,
/// Reader diagnostics retained for the recovery report.
pub diagnostics: Vec<String>,
pub read_stats: Option<codec::ReadStats>,
pub source_sha256: Option<String>,
}
/// Scan `doc` for XREF block-records, resolve their paths relative to
/// `base_dir`, and populate each xref block with entities from the
/// referenced file.
///
/// Returns a list of [`XrefInfo`] describing each xref block found, plus the
/// number of corrupt xref entities dropped during merge. Purging happens
/// inline as each xref's entities are merged — xref content is parser output
/// just like the host doc, so it gets the same corrupt-entity guard. Folding
/// it in here avoids a second full-document `entities()` walk after resolve.
pub fn resolve_xrefs(doc: &mut CadDocument, base_dir: &Path) -> (Vec<XrefInfo>, usize) {
resolve_xrefs_with_filter(doc, base_dir, None, None)
}
/// Reload only the direct xref block records identified by `keys`.
///
/// Unlike [`resolve_xrefs`], this never touches unmatched references. This is
/// the operation behind `XREF Reload <name>`: a targeted reload must not
/// silently reload (or alter the stale baseline of) every other reference.
pub fn resolve_xrefs_for_keys(
doc: &mut CadDocument,
base_dir: &Path,
keys: &HashSet<Handle>,
) -> (Vec<XrefInfo>, usize) {
resolve_xrefs_with_filter(doc, base_dir, None, Some(keys))
}
/// Resolve XREFs while reporting completed work units.
///
/// Each reference contributes 1000 parse units and 1000 merge units. Parsing
/// progress comes directly from the DWG reader when available, so one large
/// XREF advances smoothly instead of making the file-open bar appear frozen.
pub fn resolve_xrefs_with_progress(
doc: &mut CadDocument,
base_dir: &Path,
progress: Option<std::sync::Arc<dyn Fn(usize, usize) + Send + Sync>>,
) -> (Vec<XrefInfo>, usize) {
resolve_xrefs_with_filter(doc, base_dir, progress, None)
}
fn resolve_xrefs_with_filter(
doc: &mut CadDocument,
base_dir: &Path,
progress: Option<std::sync::Arc<dyn Fn(usize, usize) + Send + Sync>>,
only: Option<&HashSet<Handle>>,
) -> (Vec<XrefInfo>, usize) {
// Auto-resolve every xref — frustum + LOD culling keep GPU cost bounded.
let xref_entries: Vec<(String, String, Handle)> = doc
.block_records
.iter()
.filter(|br| (br.flags.is_xref || br.flags.is_xref_overlay) && !br.xref_path.is_empty())
.filter(|br| only.is_none_or(|keys| keys.contains(&br.handle)))
.map(|br| (br.name.clone(), br.xref_path.clone(), br.handle))
.collect();
let xref_count = xref_entries.len();
let total_units = xref_count.saturating_mul(2000);
if let Some(progress) = &progress {
progress(0, total_units);
}
// Phase 1 — parse every referenced file in parallel. Each `load_file`
// reads and decodes an independent DWG/DXF and touches nothing in the host
// `doc`, so the expensive parse of several large discipline files overlaps
// instead of running back-to-back. (The merge in phase 2 mutates `doc`, so
// it stays serial.) `resolve_path` is pure and `base_dir` is shared &-ref.
use crate::par::prelude::*;
let parse_units: std::sync::Arc<Vec<std::sync::atomic::AtomicU16>> = std::sync::Arc::new(
(0..xref_count)
.map(|_| std::sync::atomic::AtomicU16::new(0))
.collect(),
);
let parsed: Vec<(
String,
String,
Handle,
Option<PathBuf>,
Option<Result<codec::ReadOutcome, String>>,
Option<String>,
Option<SourceFingerprint>,
)> = xref_entries
.into_par_iter()
.enumerate()
.map(|(xref_index, (block_name, raw_path, br_handle))| {
let resolved = resolve_path(&raw_path, base_dir);
#[cfg(not(target_arch = "wasm32"))]
let initial_fingerprint = resolved.as_ref().and_then(|path| {
crate::io::edit_lock::FileFingerprint::capture(path).ok()
});
#[cfg(target_arch = "wasm32")]
let initial_fingerprint = None;
let units = std::sync::Arc::clone(&parse_units);
let nested_progress = progress.as_ref().map(|progress| {
let progress = std::sync::Arc::clone(progress);
let callback: std::sync::Arc<dyn Fn(u16) + Send + Sync> =
std::sync::Arc::new(move |value| {
units[xref_index]
.store(value.min(1000), std::sync::atomic::Ordering::Relaxed);
let completed = units
.iter()
.map(|unit| unit.load(std::sync::atomic::Ordering::Relaxed) as usize)
.sum();
progress(completed, total_units);
});
callback
});
let xref_outcome = resolved
.as_ref()
.map(|path| super::load_file_with_progress(path, nested_progress));
let source_sha256 = resolved.as_ref().and_then(|path| {
let needs_fingerprint = match &xref_outcome {
Some(Ok(outcome)) => {
outcome.stats.recovered()
|| outcome.stats.skipped_source_records > 0
|| !outcome.stats.stream_completed
}
Some(Err(_)) => true,
None => false,
};
if !needs_fingerprint {
return None;
}
#[cfg(not(target_arch = "wasm32"))]
{
super::stable_sha256_file(path, initial_fingerprint.as_ref())
}
#[cfg(target_arch = "wasm32")]
{
crate::io::recovery::sha256_file(path).ok()
}
});
parse_units[xref_index].store(1000, std::sync::atomic::Ordering::Relaxed);
if let Some(progress) = &progress {
let completed = parse_units
.iter()
.map(|unit| unit.load(std::sync::atomic::Ordering::Relaxed) as usize)
.sum();
progress(completed, total_units);
}
(
block_name,
raw_path,
br_handle,
resolved,
xref_outcome,
source_sha256,
initial_fingerprint,
)
})
.collect();
// Phase 2 — merge each parsed xref into the host document, in the original
// block order (par_iter preserves it), so handle allocation is deterministic.
let mut result = Vec::with_capacity(parsed.len());
let mut dropped = 0usize;
#[allow(unused_mut, unused_variables)]
for (
merge_index,
(
block_name,
raw_path,
br_handle,
resolved,
xref_outcome,
mut source_sha256,
initial_fingerprint,
),
) in parsed.into_iter().enumerate()
{
let (status, diagnostics, read_stats) = match xref_outcome {
Some(Ok(mut outcome)) => {
let recovered = outcome.stats.recovered()
|| outcome.stats.skipped_source_records > 0
|| !outcome.stats.stream_completed
|| outcome.document.notifications.iter().any(|item| {
item.notification_type == codec::notification::NotificationType::Error
});
let mut diagnostics: Vec<String> = outcome
.document
.notifications
.iter()
.map(ToString::to_string)
.collect();
diagnostics.extend(
outcome
.stats
.diagnostics
.iter()
.map(|diagnostic| diagnostic.message.clone()),
);
let invalid = super::purge_corrupt_entities(&mut outcome.document);
if invalid > 0 {
diagnostics.push(format!(
"normal read found {invalid} structurally invalid reference records"
));
#[cfg(not(target_arch = "wasm32"))]
if source_sha256.is_none() {
source_sha256 = resolved.as_ref().and_then(|path| {
super::stable_sha256_file(path, initial_fingerprint.as_ref())
});
}
}
if recovered || invalid > 0 {
(XrefStatus::Failed, diagnostics, Some(outcome.stats))
} else {
// Reload is replacement, never append. Keep the prior merge
// intact until parsing succeeded, then discard its generated
// entities, sortents, and dependent symbols immediately before
// importing the replacement content.
//
// VISRETAIN=1 (`header.retain_xref_visibility`): host-side
// layer overrides survive the purge — snapshot them first and
// re-apply onto the freshly merged layers below. Without this
// every reload silently reset frozen/off/color tweaks the
// user made on `name|*` layers back to file defaults.
let visretain = doc.header.retain_xref_visibility;
let kept_layers = if visretain {
snapshot_dependent_layers(doc, &block_name)
} else {
HashMap::default()
};
let _ = unload_reference(doc, br_handle.value());
remove_pipe_symbols(doc, &block_name);
ensure_block_entities(doc, &block_name);
dropped += merge_xref_into_block(
doc,
&block_name,
br_handle,
outcome.document,
);
if visretain {
restore_dependent_layers(doc, &block_name, kept_layers);
}
(XrefStatus::Loaded, diagnostics, Some(outcome.stats))
}
}
Some(Err(error)) => (XrefStatus::Failed, vec![error], None),
None => (XrefStatus::NotFound, Vec::new(), None),
};
result.push(XrefInfo {
name: block_name,
path: resolved
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or(raw_path),
status,
diagnostics,
read_stats,
source_sha256,
});
if let Some(progress) = &progress {
progress(
xref_count
.saturating_mul(1000)
.saturating_add((merge_index + 1).saturating_mul(1000)),
total_units,
);
}
}
(result, dropped)
}
/// Underlay files named by a relative (or moved) path: read them from where
/// the drawing is and register the bytes under the stored path, which is
/// what the underlay's definition names.
#[cfg(not(target_arch = "wasm32"))]
pub fn register_underlay_sources(doc: &CadDocument, base_dir: &Path) {
for object in doc.objects.values() {
let codec::objects::ObjectType::UnderlayDefinition(def) = object else {
continue;
};
let stored = def.file_path.as_str();
if stored.is_empty() || Path::new(stored).is_absolute() && Path::new(stored).exists() {
continue;
}
if let Some(bytes) = resolve_path(stored, base_dir).and_then(|p| std::fs::read(p).ok()) {
crate::scene::model::pdf_raster::register_source(stored, std::sync::Arc::new(bytes));
}
}
}
/// Layer properties an override can change, as compared for the
/// "Layer property overrides" row.
#[derive(Clone, PartialEq)]
struct LayerLook {
color: codec::types::Color,
line_type: String,
line_weight: codec::types::LineWeight,
off: bool,
frozen: bool,
locked: bool,
plottable: bool,
}
impl LayerLook {
fn of(layer: &codec::tables::Layer) -> Self {
Self {
color: layer.color.clone(),
line_type: layer.line_type.to_uppercase(),
line_weight: layer.line_weight,
off: layer.flags.off,
frozen: layer.flags.frozen,
locked: layer.flags.locked,
plottable: layer.is_plottable,
}
}
}
/// The source file's layers by upper-case name, read once per file version.
fn source_layers(path: &Path) -> Option<HashMap<String, LayerLook>> {
type Cache = std::sync::Mutex<HashMap<PathBuf, (std::time::SystemTime, HashMap<String, LayerLook>)>>;
static CACHE: std::sync::OnceLock<Cache> = std::sync::OnceLock::new();
let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
let cache = CACHE.get_or_init(Default::default);
if let Some((stamp, layers)) = cache.lock().ok()?.get(path) {
if *stamp == modified {
return Some(layers.clone());
}
}
let doc = super::load_file(path).ok()?;
let layers: HashMap<String, LayerLook> = doc
.layers
.iter()
.map(|layer| (layer.name.to_uppercase(), LayerLook::of(layer)))
.collect();
cache
.lock()
.ok()?
.insert(path.to_path_buf(), (modified, layers.clone()));
Some(layers)
}
/// Whether any of reference `name`'s layers was changed in the host — the
/// Properties "Layer property overrides" row. Compares the host's dependent
/// layers with the source file's own.
pub fn layer_overrides(doc: &CadDocument, name: &str, raw_path: &str, base_dir: &Path) -> bool {
let Some(source) = resolve_path(raw_path, base_dir).and_then(|p| source_layers(&p)) else {
return false;
};
let prefix = format!("{}|", name.to_uppercase());
doc.layers.iter().any(|layer| {
let upper = layer.name.to_uppercase();
let Some(own) = upper.strip_prefix(&prefix) else {
return false;
};
let Some(original) = source.get(own) else {
return false;
};
let mut here = LayerLook::of(layer);
// Dependent linetypes carry the reference prefix.
if let Some(stripped) = here.line_type.strip_prefix(&prefix) {
here.line_type = stripped.to_string();
}
here != *original
})
}
/// Try to build an absolute path from a raw xref path string.
/// Handles absolute paths, relative paths, and Windows-style separators.
fn resolve_path(raw: &str, base_dir: &Path) -> Option<PathBuf> {
let normalised = raw.replace('\\', "/");
let p = PathBuf::from(&normalised);
if p.is_absolute() {
if p.exists() {
return Some(p);
}
// Fallback: try the filename in base_dir.
if let Some(fname) = p.file_name() {
let c = base_dir.join(fname);
if c.exists() {
return Some(c);
}
}
return None;
}
// Relative path against base_dir.
let candidate = base_dir.join(&p);
if candidate.exists() {
return Some(candidate);
}
// Last resort: just the filename.
if let Some(fname) = p.file_name() {
let c = base_dir.join(fname);
if c.exists() {
return Some(c);
}
}
None
}
/// Give a block record its BLOCK / ENDBLK markers, linked through the
/// record's marker handles. The DWG writer emits the markers the record
/// points at; a record left pointing at a null handle is written with
/// handle 0, which makes the whole drawing unreadable elsewhere.
pub(crate) fn ensure_block_entities(doc: &mut CadDocument, block_name: &str) {
let Some((owner, begin, end, path)) = doc.block_records.get(block_name).map(|br| {
(
br.handle,
br.block_entity_handle,
br.block_end_handle,
br.xref_path.clone(),
)
}) else {
return;
};
if begin.is_null() || !matches!(doc.get_entity(begin), Some(EntityType::Block(_))) {
let handle = doc.allocate_handle();
let mut block = Block::new(block_name, Vector3::zero());
if !path.is_empty() {
block = block.with_xref_path(&path);
}
block.common.handle = handle;
block.common.owner_handle = owner;
let _ = doc.add_entity(EntityType::Block(block));
if let Some(br) = doc.block_records.get_mut(block_name) {
br.block_entity_handle = handle;
}
}
if end.is_null() || !matches!(doc.get_entity(end), Some(EntityType::BlockEnd(_))) {
let handle = doc.allocate_handle();
let mut block_end = BlockEnd::new();
block_end.common.handle = handle;
block_end.common.owner_handle = owner;
let _ = doc.add_entity(EntityType::BlockEnd(block_end));
if let Some(br) = doc.block_records.get_mut(block_name) {
br.block_end_handle = handle;
}
}
}
/// Shared symbol-remap core for XREF load-merge and BIND (SPIKE3 REFACTOR
/// verdict: extract, don't duplicate).
///
/// Both paths import the source doc's symbol tables into the host and rewrite
/// every entity reference to the imported copies. They differ only in naming:
/// the load path prefixes `{xref}|{sym}` (collisions overwrite), BIND renames
/// `{parent}$N${sym}` (N bumped past collisions).
///
/// `bind_limitations` — symbol references this helper can NOT remap (counted
/// as unremapped; BIND reports the count per bind, the load path ignores it):
/// - MLINE `style_name`/`style_handle`: no host MLineStyle-table import —
/// the handle is nulled, the name kept verbatim.
/// - Material / plotstyle / full-face-edge visual-style / color-book handles
/// (`EntityCommon` + ACIS `material_handle` bindings on `Solid3D`): the
/// target objects are never imported — handles nulled.
/// - Tolerance `dimension_style_handle`, Shape `style_handle`, MultiLeader
/// `style_handle`/`text_style_handle`, Table `table_style_handle` and an
/// unmapped `block_record_handle`: nulled.
/// - Kept verbatim (audited, benign): linetype handles (the name carries the
/// lookup), Leader `annotation_handle`, Hatch `boundary_handles`,
/// `attdef_handle`, reactors, xdata, extension dictionaries, graphic data,
/// Dimension anonymous-block names (`*D…` — geometry regenerates from the
/// style), Hatch pattern names (no pattern table exists), Shape style
/// names, MultiLeader line-type/arrowhead handles, Table cell internals
/// (rows/cells keep xref text-style names), Viewport visual-style handles,
/// SectionSymbol style handles, `Extended` entities.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
enum SymbolKind {
Layer,
Linetype,
TextStyle,
DimStyle,
Block,
}
/// Per-kind collision sets for BIND naming, preloaded from the host doc
/// (uppercased — table lookup is case-insensitive).
#[derive(Debug, Default)]
struct BindTaken {
layers: HashSet<String>,
linetypes: HashSet<String>,
text_styles: HashSet<String>,
dim_styles: HashSet<String>,
blocks: HashSet<String>,
}
impl BindTaken {
fn capture(doc: &CadDocument) -> Self {
let upper_names =
|names: Vec<String>| names.into_iter().map(|n| n.to_uppercase()).collect();
Self {
layers: upper_names(doc.layers.names().map(|s| s.to_string()).collect()),
linetypes: upper_names(doc.line_types.names().map(|s| s.to_string()).collect()),
text_styles: upper_names(
doc.text_styles.names().map(|s| s.to_string()).collect(),
),
dim_styles: upper_names(doc.dim_styles.names().map(|s| s.to_string()).collect()),
blocks: upper_names(
doc.block_records
.iter()
.map(|b| b.name.clone())
.collect(),
),
}
}
fn set(&mut self, kind: SymbolKind) -> &mut HashSet<String> {
match kind {
SymbolKind::Layer => &mut self.layers,
SymbolKind::Linetype => &mut self.linetypes,
SymbolKind::TextStyle => &mut self.text_styles,
SymbolKind::DimStyle => &mut self.dim_styles,
SymbolKind::Block => &mut self.blocks,
}
}
/// Claim `{parent}$N${sym}`, bumping N past every taken name, and record
/// the winner so sequential imports compose transitive chains like
/// `PLAN$0$DETAIL$0$WALLS`.
fn claim(&mut self, kind: SymbolKind, parent: &str, sym: &str) -> String {
let mut n = 0u32;
loop {
let candidate = format!("{parent}${n}${sym}");
if self.set(kind).insert(candidate.to_uppercase()) {
return candidate;
}
n += 1;
}
}
}
/// How imported symbol names are formed.
enum SymbolNaming {
/// Load path: `{prefix}|{sym}` (collisions overwrite via add_or_replace).
MergePipe { prefix: String },
/// BIND path: `{parent}$N${sym}` with per-kind collision dedup.
BindDollar { parent: String, taken: BindTaken },
}
impl SymbolNaming {
fn name_for(&mut self, kind: SymbolKind, sym: &str) -> String {
match self {
SymbolNaming::MergePipe { prefix } => format!("{prefix}|{sym}"),
SymbolNaming::BindDollar { parent, taken } => taken.claim(kind, parent, sym),
}
}
}
/// Imported-name maps for one source document (old UPPER name → new name,
/// old handle → new handle).
#[derive(Default)]
struct XrefSymbolMaps {
layers: HashMap<String, String>,
linetypes: HashMap<String, String>,
text_styles: HashMap<String, String>,
text_style_handles: HashMap<Handle, Handle>,
dim_styles: HashMap<String, String>,
blocks: HashMap<String, String>,
br_handles: HashMap<Handle, Handle>,
image_defs: HashMap<Handle, Handle>,
underlay_defs: HashMap<Handle, Handle>,
}
/// Import a source doc's symbol tables + IMAGE/UNDERLAY definitions into the
/// host doc under `naming`, returning the old→new maps for entity remap.
///
/// Covers layers (incl. "0"), linetypes (minus sentinels), text styles,
/// dim styles, nested block records (layout `*` records and nested xrefs
/// skipped — BIND resolves those before merging), and every image/underlay
/// definition (fresh handle, detached owner, verbatim path — BIND retargets
/// image paths via COLLECT before merging).
fn import_xref_symbols(
doc: &mut CadDocument,
xref_doc: &CadDocument,
naming: &mut SymbolNaming,
) -> XrefSymbolMaps {
let mut maps = XrefSymbolMaps::default();
// A layer's plot style and material point at objects of the file it came
// from; carried over verbatim they point at whatever the host keeps under
// those handles. Take the host's defaults (its layer 0) instead.
// ponytail: every imported layer gets the default plot style and material;
// map them by name if a reference ever carries non-default ones.
let (host_plotstyle, host_material) = doc
.layers
.get("0")
.map(|layer| (layer.plotstyle_handle, layer.material))
.unwrap_or((Handle::NULL, Handle::NULL));
for layer in xref_doc.layers.iter() {
let old = layer.name.clone();
let new = naming.name_for(SymbolKind::Layer, &old);
let mut cloned = layer.clone();
cloned.name = new.clone();
cloned.plotstyle_handle = host_plotstyle;
cloned.material = host_material;
cloned.set_handle(doc.allocate_handle());
doc.layers.add_or_replace(cloned);
maps.layers.insert(old.to_uppercase(), new);
}
for ts in xref_doc.text_styles.iter() {
// A shape-file style has no name of its own; the host's style for the
// same shape file serves the imported linetypes.
if ts.is_shape_file {
let same_file = doc
.text_styles
.iter()
.find(|host| host.is_shape_file && host.font_file.eq_ignore_ascii_case(&ts.font_file))
.map(|host| host.handle);
if let Some(host_handle) = same_file {
maps.text_style_handles.insert(ts.handle, host_handle);
continue;
}
}
let old = ts.name.clone();
let new = naming.name_for(SymbolKind::TextStyle, &old);
let mut cloned = ts.clone();
cloned.name = new.clone();
let new_handle = doc.allocate_handle();
cloned.set_handle(new_handle);
cloned.xref_dependent = false;
doc.text_styles.add_or_replace(cloned);
maps.text_style_handles.insert(ts.handle, new_handle);
maps.text_styles.insert(old.to_uppercase(), new);
}
for lt in xref_doc.line_types.iter() {
let old = lt.name.clone();
if is_sentinel_linetype(&old) {
continue;
}
let new = naming.name_for(SymbolKind::Linetype, &old);
let mut cloned = lt.clone();
cloned.name = new.clone();
// Shape and text segments name their style by handle: point them at
// the imported copy, not at the source file's record.
for element in &mut cloned.elements {
if let Some(complex) = element.complex.as_mut() {
if let Some(new_style) = maps.text_style_handles.get(&complex.style_handle) {
complex.style_handle = *new_style;
}
}
}
cloned.set_handle(doc.allocate_handle());
doc.line_types.add_or_replace(cloned);
maps.linetypes.insert(old.to_uppercase(), new);
}
for ds in xref_doc.dim_styles.iter() {
let old = ds.name.clone();
let new = naming.name_for(SymbolKind::DimStyle, &old);
let mut cloned = ds.clone();
cloned.name = new.clone();
cloned.set_handle(doc.allocate_handle());
cloned.xref_reference = false;
cloned.xref_resolved = false;
cloned.xref_dependent = false;
cloned.xref_handle = Handle::NULL;
doc.dim_styles.add_or_replace(cloned);
maps.dim_styles.insert(old.to_uppercase(), new);
}
// Nested block records: prefixed clones with cleared membership, detached
// layout pointer, fresh handles (same shape as the load path always made).
for br in xref_doc.block_records.iter() {
if br.name.starts_with('*') || br.flags.is_xref || br.flags.is_xref_overlay {
continue;
}
let old = br.name.clone();
let new = naming.name_for(SymbolKind::Block, &old);
let mut cloned = br.clone();
cloned.name = new.clone();
cloned.entity_handles.clear();
cloned.insert_handles.clear();
cloned.layout = Handle::NULL;
let new_h = doc.allocate_handle();
cloned.set_handle(new_h);
cloned.block_entity_handle = doc.allocate_handle();
cloned.block_end_handle = doc.allocate_handle();
maps.br_handles.insert(br.handle, new_h);
maps.blocks.insert(old.to_uppercase(), new);
doc.block_records.add_or_replace(cloned);
}
// IMAGE / UNDERLAY definitions: the entities reference these by handle,
// so without an import every bound image loses its file link. Fresh
// handle, detached owner; the path stays verbatim here.
{
use codec::objects::ObjectType;
for (handle, obj) in xref_doc.objects.iter() {
match obj {
ObjectType::ImageDefinition(def) => {
let mut cloned = def.clone();
let new_h = doc.allocate_handle();
cloned.handle = new_h;
cloned.owner = Handle::NULL;
doc.objects
.insert(new_h, ObjectType::ImageDefinition(cloned));
maps.image_defs.insert(*handle, new_h);
}
ObjectType::UnderlayDefinition(def) => {
let mut cloned = def.clone();
let new_h = doc.allocate_handle();
cloned.handle = new_h;
cloned.owner_handle = Handle::NULL;
cloned.reactors.clear();
doc.objects
.insert(new_h, ObjectType::UnderlayDefinition(cloned));
maps.underlay_defs.insert(*handle, new_h);
}
_ => {}
}
}
}
maps
}
/// Null one optional foreign handle, counting it as unremapped when set.
/// Returns 1 when a handle was cleared, 0 otherwise.
fn null_handle(handle: &mut Option<Handle>) -> usize {
if handle.is_some() {
*handle = None;
1
} else {
0
}
}
/// Rewrite one entity's symbol references to the imported copies in place.
/// Returns the count of unremappable foreign handles (see
/// `bind_limitations` above) — ignored on the load path, reported per BIND.
fn remap_xref_entity(
host: &CadDocument,
entity: &mut EntityType,
maps: &XrefSymbolMaps,
) -> usize {
let mut unremapped = 0usize;
let rename = |map: &HashMap<String, String>, name: &mut String| {
if let Some(new) = map.get(&name.to_uppercase()) {
*name = new.clone();
}
};
// ── Common layer / linetype + material-style handles ──
{
let c = entity.common_mut();
rename(&maps.layers, &mut c.layer);
if !is_sentinel_linetype(&c.linetype) {
rename(&maps.linetypes, &mut c.linetype);
}
unremapped += null_handle(&mut c.material_handle);
unremapped += null_handle(&mut c.plotstyle_handle);
unremapped += null_handle(&mut c.full_visual_style_handle);
unremapped += null_handle(&mut c.face_visual_style_handle);
unremapped += null_handle(&mut c.edge_visual_style_handle);
unremapped += null_handle(&mut c.color_book_handle);
}
// ── Text-style names (incl. embedded MText in attributes) ──
let remap_mtext_style = |mtext: &mut codec::entities::MText, maps: &XrefSymbolMaps| {
rename(&maps.text_styles, &mut mtext.style);
};
match entity {
EntityType::Text(t) => rename(&maps.text_styles, &mut t.style),
EntityType::MText(m) => remap_mtext_style(m, maps),
EntityType::AttributeDefinition(a) => {
rename(&maps.text_styles, &mut a.text_style);
if let Some(m) = a.embedded_mtext.as_mut() {
remap_mtext_style(m, maps);
}
}
EntityType::AttributeEntity(a) => {
rename(&maps.text_styles, &mut a.text_style);
if let Some(m) = a.embedded_mtext.as_mut() {
remap_mtext_style(m, maps);
}
}
// ── Dim-style names (+ anonymous-block refs stay verbatim) ──
EntityType::Dimension(d) => rename(&maps.dim_styles, &mut d.base_mut().style_name),
EntityType::Leader(l) => rename(&maps.dim_styles, &mut l.dimension_style),
EntityType::Tolerance(t) => {
rename(&maps.dim_styles, &mut t.dimension_style_name);
unremapped += null_handle(&mut t.dimension_style_handle);
}
// ── Block refs by name ──
EntityType::Insert(ins) => rename(&maps.blocks, &mut ins.block_name),
// ── Table: style handles nulled, legacy block refs remapped ──
EntityType::Table(t) => {
unremapped += null_handle(&mut t.table_style_handle);
if let Some(br) = t.block_record_handle {
match maps.br_handles.get(&br) {
Some(&new_br) => t.block_record_handle = Some(new_br),
None => {
t.block_record_handle = None;
unremapped += 1;
}
}
}
rename(&maps.blocks, &mut t.block_name);
}
// ── MultiLeader / MLine / Shape style handles (names verbatim) ──
EntityType::MultiLeader(m) => {
unremapped += null_handle(&mut m.style_handle);
unremapped += null_handle(&mut m.text_style_handle);
}
EntityType::MLine(m) => {
unremapped += null_handle(&mut m.style_handle);
}
EntityType::Shape(s) => {
unremapped += null_handle(&mut s.style_handle);
}
EntityType::Solid3D(s) => {
for m in &mut s.acis_data.materials {
unremapped += null_handle(&mut m.material_handle);
}
}
// ── IMAGE / UNDERLAY definition links ──
EntityType::RasterImage(img) => {
if let Some(old) = img.definition_handle {
if let Some(&new) = maps.image_defs.get(&old) {
img.definition_handle = Some(new);
// Keep the entity path in sync with its definition (bind
// COLLECT retargets the definition before merging).
use codec::objects::ObjectType;
if let Some(ObjectType::ImageDefinition(def)) = host.objects.get(&new) {
img.file_path = def.file_name.clone();
}
}
}
img.definition_reactor_handle = None;
}
EntityType::Underlay(ul) => {
if let Some(&new) = maps.underlay_defs.get(&ul.definition_handle) {
ul.definition_handle = new;
}
}
_ => {}
}
unremapped
}
/// Re-point a source doc's draw-order tables at the merged blocks/handles, so
/// an explicit DRAWORDER inside the source survives the merge.
fn copy_xref_sortents(
doc: &mut CadDocument,
xref_doc: &CadDocument,
target_owner: Handle,
br_handle_map: &HashMap<Handle, Handle>,
entity_handle_map: &HashMap<Handle, Handle>,
) {
use codec::objects::{ObjectType, SortEntitiesTable};
let xref_ms_handle = xref_doc.header.model_space_block_handle;
for obj in xref_doc.objects.values() {
let ObjectType::SortEntitiesTable(t) = obj else {
continue;
};
if t.is_empty() {
continue;
}
let new_block = if t.block_owner_handle == xref_ms_handle {
target_owner
} else if let Some(&h) = br_handle_map.get(&t.block_owner_handle) {
h
} else {
continue;
};
let mut nt = SortEntitiesTable::new();
nt.handle = doc.allocate_handle();
nt.block_owner_handle = new_block;
for e in t.entries() {
let Some(&ne) = entity_handle_map.get(&e.entity_handle) else {
continue;
};
let ns = entity_handle_map
.get(&e.sort_handle)
.copied()
.unwrap_or(e.sort_handle);
nt.add_entry(ne, ns);
}
if !nt.is_empty() {
doc.objects.insert(nt.handle, ObjectType::SortEntitiesTable(nt));
}
}
}
/// Merge an external-reference document into `doc`'s xref block.
///
/// Copies the xref's model-space entities into the host xref block, AND
/// (crucially for correct rendering) merges the xref's layer / linetype
/// tables and any *nested* block records into the host doc under prefixed
/// names ("{xref_name}|{symbol_name}"). Without this remapping, every
/// xref entity using ByLayer resolves against the host doc's layer table
/// — which doesn't know about the xref's layers — and silently falls
/// back to WHITE / 1 px line weight. AutoCAD's BIND command uses the
/// same naming scheme.
/// Returns the number of corrupt entities skipped during the merge.
fn merge_xref_into_block(
doc: &mut CadDocument,
xref_block_name: &str,
br_handle: Handle,
xref_doc: CadDocument,
) -> usize {
let prefix = xref_block_name;
// Carry the xref's INSUNITS onto the host BlockRecord. Used at INSERT
// time so the inserted xref scales to the host's units (INSUNITS).
let src_insunits = xref_doc.header.insertion_units;
if let Some(br) = doc.block_records.iter_mut().find(|b| b.handle == br_handle) {
br.units = src_insunits;
}
// ── Symbol tables + definitions (shared helper) ─────────────────────
// Same prefixed scheme as before ("{xref}|{sym}"); the helper additionally
// imports text styles, dim styles, and IMAGE/UNDERLAY definitions.
let mut naming = SymbolNaming::MergePipe {
prefix: prefix.to_string(),
};
let maps = import_xref_symbols(doc, &xref_doc, &mut naming);
// Dependent layers and linetypes carry the xref flag and their owning
// reference, the way the file stores them; without it the host writes
// plain records with a "|" in the name, which other readers reject.
for name in maps.layers.values() {
if let Some(layer) = doc.layers.get_mut(name) {
layer.flags.xref_dependent = true;
layer.xref_block_record_handle = br_handle;
}
}
for name in maps.linetypes.values() {
if let Some(linetype) = doc.line_types.get_mut(name) {
linetype.xref_dependent = true;
linetype.xref_block_record_handle = br_handle;
}
}
// ── Entities (shared helper) ────────────────────────────────────────
// The unremapped-handle count is ignored on the load path (BIND reports
// it per bind).
let (dropped, _, entity_handle_map) =
merge_source_entities(doc, &xref_doc, br_handle, &maps);
// ── Draw-order overrides (shared helper) ──────────────────────────────
copy_xref_sortents(doc, &xref_doc, br_handle, &maps.br_handles, &entity_handle_map);
dropped
}
/// Source entities in display order: each block record's `entity_handles`
/// chain is the draw order inside that block, while the flat entity list is
/// stream order — merging in stream order scrambles the source's own stacking
/// (fills over outlines etc.), since the host ranks draw order by the
/// freshly-allocated handles. Entities missing from every chain keep their
/// stream order at the end. BLOCK/BlockEnd markers are excluded.
fn ordered_source_entities(xref_doc: &CadDocument) -> Vec<(Handle, EntityType)> {
let mut ordered: Vec<Handle> = Vec::new();
let mut seen: HashSet<Handle> = HashSet::default();
for br in xref_doc.block_records.iter() {
for &h in &br.entity_handles {
if seen.insert(h) {
ordered.push(h);
}
}
}
for e in xref_doc.entities() {
let h = e.common().handle;
if seen.insert(h) {
ordered.push(h);
}
}
ordered
.into_iter()
.filter_map(|h| xref_doc.get_entity(h).map(|e| (h, e.clone())))
.filter(|(_, e)| !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)))
.collect()
}
/// Merge one source doc's entities into `target`, routing model-space content
/// into `target_br` and nested-block content into its imported counterpart
/// (`maps.br_handles`). Returns corrupt-drop count, unremapped-handle count,
/// and the old→new entity handle map for the sortents copy.
fn merge_source_entities(
target: &mut CadDocument,
source: &CadDocument,
target_br: Handle,
maps: &XrefSymbolMaps,
) -> (usize, usize, HashMap<Handle, Handle>) {
let source_ms_handle = source.header.model_space_block_handle;
let mut entity_handle_map: HashMap<Handle, Handle> = HashMap::default();
let mut dropped = 0usize;
let mut unremapped = 0usize;
for (old_h, mut entity) in ordered_source_entities(source) {
// Drop parser-garbage entities (bad normals / vertex counts / inf
// coords) before they enter the host doc — they trigger huge
// allocations and unbounded recursion in the wire pipeline.
if super::is_entity_corrupt(&entity) {
dropped += 1;
continue;
}
// Remap every symbol reference so the host's resolver hits the copies
// just imported (load path ignores the unremapped count; BIND reports
// it per bind).
unremapped += remap_xref_entity(target, &mut entity, maps);
// Route entity to the correct host block record. Entities owned by
// the source model_space land in the target block; entities owned by
// one of the nested BRs land in its imported counterpart.
// Paper-space / unknown owners are skipped.
let old_owner = entity.common().owner_handle;
let new_owner = if old_owner == source_ms_handle {
target_br
} else if let Some(&h) = maps.br_handles.get(&old_owner) {
h
} else {
continue;
};
entity.common_mut().owner_handle = new_owner;
// Clear the foreign handle so opencadcodec assigns a new one.
set_handle(&mut entity, Handle::NULL);
if let Ok(new_h) = target.add_entity(entity) {
entity_handle_map.insert(old_h, new_h);
}
}
(dropped, unremapped, entity_handle_map)
}
fn is_sentinel_linetype(name: &str) -> bool {
name.eq_ignore_ascii_case("ByLayer")
|| name.eq_ignore_ascii_case("ByBlock")
|| name.eq_ignore_ascii_case("Continuous")
}
/// Set the handle field of any entity variant.
fn set_handle(entity: &mut EntityType, h: Handle) {
entity.common_mut().handle = h;
}
/// Re-exported so CLI + tests share one derivation with the model.
pub use crate::io::xref_model::child_key;
/// Unified reference list for the XREF manager.
///
/// Scans `doc` for DWG xref block-records, RasterImage-linked image
/// definitions, and PDF underlay definitions. `saved_path` is always the raw
/// stored string verbatim; `found_at`/`size_bytes`/`modified` come from
/// [`resolve_path`] against `base_dir`. Keys in `unloaded` come back as
/// `Unloaded` (loaded=false) without touching the filesystem.
///
/// `prev` holds cached load-time mtimes per key (see
/// [`RefStatCache`](crate::io::xref_model::RefStatCache)). Empty on first
/// call → no `Stale` on bootstrap. When both the live mtime and the cached
/// baseline exist and live > cached + 1s slack, a `Loaded` entry reports
/// `Stale`. Nested children whose host entry is `NotFound`/`Failed` report
/// `Orphaned` (overriding `Loaded`).
///
/// Key namespacing: top-level keys are host handle values (unchanged).
/// Nested child keys are
/// [`child_key`](crate::io::xref_model::child_key)`(parent_key, name,
/// saved_path)` so foreign handles that collide with host handles never
/// alias a host row. Both `collect_entries` and the palette refresh path go
/// through this fn, and the palette keys rows off `(key, saved_path)`, so
/// nested rows keep resolving.
///
/// Nested enumeration (SPIKE5): each LOADED DwgXref whose file resolved is
/// opened read-only and its *direct* xref block-records are appended as child
/// entries (no geometry merge). Parsed docs live in a per-path
/// cache inside the call; already-seen normalized `saved_path`s are skipped,
/// so a self- or cyclic reference enumerates once and never recurses. A
/// nested file that fails to parse is skipped silently. Synchronous by
/// design; async stat is Task 8.
pub fn collect_entries(
doc: &codec::CadDocument,
base_dir: &Path,
unloaded: &std::collections::HashSet<crate::io::xref_model::UnloadKey>,
) -> Vec<crate::io::xref_model::ReferenceEntry> {
collect_entries_with_prev(doc, base_dir, unloaded, &std::collections::HashMap::new())
}
/// [`collect_entries`] with a load-time mtime baseline for `Stale` detection.
/// See [`collect_entries`] for the full contract.
pub fn collect_entries_with_prev(
doc: &codec::CadDocument,
base_dir: &Path,
unloaded: &std::collections::HashSet<crate::io::xref_model::UnloadKey>,
prev: &std::collections::HashMap<u64, std::time::SystemTime>,
) -> Vec<crate::io::xref_model::ReferenceEntry> {
use codec::objects::ObjectType;
use crate::io::xref_model::{
child_key, decide_status, normalize_lexical, RefKind, RefStatus, RefType,
ReferenceEntry, UnloadKey,
};
/// Resolve `raw` via `base_dir`; on success record `found_at`, stat
/// size/mtime, and flip the entry to Loaded. Missing/empty stays NotFound.
///
/// The filesystem stat is desktop-only by `cfg` (not a runtime check): the
/// wasm binary contains no `std::fs::metadata` call at all, so a web
/// session can never block on — or fail from — a host-filesystem probe.
/// Path-based web references resolve through the file-handle loader and
/// simply stay `NotFound` when unresolvable.
fn stat_into(entry: &mut ReferenceEntry, raw: &str, base_dir: &Path) {
if raw.is_empty() {
return;
}
let Some(found) = resolve_path(raw, base_dir) else {
return;
};
entry.found_at = Some(found.to_string_lossy().into_owned());
entry.status = RefStatus::Loaded;
#[cfg(not(target_arch = "wasm32"))]
if let Ok(meta) = std::fs::metadata(&found) {
entry.size_bytes = Some(meta.len());
entry.modified = meta.modified().ok();
}
}
/// Resolve `raw` into `found_at` without statting: unloaded rows keep no
/// size/date (spec) but still know where their file lives, so previews
/// and path operations work without loading.
fn resolve_only(entry: &mut ReferenceEntry, raw: &str, base_dir: &Path) {
if raw.is_empty() {
return;
}
if let Some(found) = resolve_path(raw, base_dir) {
entry.found_at = Some(found.to_string_lossy().into_owned());
}
}
fn file_name_only(raw: &str) -> &str {
raw.rsplit(['/', '\\']).next().unwrap_or(raw)
}
let mut entries: Vec<ReferenceEntry> = Vec::new();
// ── DWG xrefs ──
for br in doc.block_records.iter() {
if !(br.flags.is_xref || br.flags.is_xref_overlay) {
continue;
}
let key = br.handle.value();
let mut entry = ReferenceEntry::new(key, br.name.clone(), RefKind::DwgXref);
entry.ref_type = if br.flags.is_xref_overlay {
RefType::Overlay
} else {
RefType::Attach
};
entry.saved_path = br.xref_path.clone();
if unloaded.contains(&UnloadKey::Direct(key)) {
entry.status = RefStatus::Unloaded;
resolve_only(&mut entry, &br.xref_path, base_dir);
} else {
stat_into(&mut entry, &br.xref_path, base_dir);
entry.status = decide_status(
entry.status,
entry.modified,
prev.get(&key).copied(),
false,
);
}
entries.push(entry);
}
// ── Raster images ────────────────────────────────────────────────────
// Include unreferenced definitions too. They are actionable file
// references (and standard reference managers expose them) rather than
// silently disappearing from the palette.
let mut referenced_images: HashSet<Handle> = HashSet::default();
for e in doc.entities() {
if let EntityType::RasterImage(img) = e {
if let Some(h) = img.definition_handle {
referenced_images.insert(h);
}
}
}
for (handle, obj) in doc.objects.iter() {
let ObjectType::ImageDefinition(def) = obj else {
continue;
};
let is_unreferenced = !referenced_images.contains(handle);
let key = handle.value();
let mut entry = ReferenceEntry::new(key, file_name_only(&def.file_name), RefKind::Image);
entry.saved_path = def.file_name.clone();
if unloaded.contains(&UnloadKey::Direct(key)) {
entry.status = RefStatus::Unloaded;
resolve_only(&mut entry, &def.file_name, base_dir);
} else {
stat_into(&mut entry, &def.file_name, base_dir);
entry.status = decide_status(
entry.status,
entry.modified,
prev.get(&key).copied(),
false,
);
if is_unreferenced {
entry.status = RefStatus::Unreferenced;
}
}
entries.push(entry);
}
// ── PDF underlays ──
// Same referenced/unreferenced split as raster images: an underlay
// definition no entity points at lists as Unreferenced (manageable, never
// merged) rather than vanishing from the palette.
let mut referenced_underlays: HashSet<Handle> = HashSet::default();
for e in doc.entities() {
if let EntityType::Underlay(u) = e {
if u.definition_handle.is_valid() {
referenced_underlays.insert(u.definition_handle);
}
}
}
for (handle, obj) in doc.objects.iter() {
let ObjectType::UnderlayDefinition(def) = obj else {
continue;
};
let key = handle.value();
let name = if def.name.trim().is_empty() {
file_name_only(&def.file_path).to_owned()
} else {
def.name.clone()
};
let mut entry = ReferenceEntry::new(key, name, RefKind::Underlay);
entry.saved_path = def.file_path.clone();
if def.unloaded || unloaded.contains(&UnloadKey::Direct(key)) {
entry.status = RefStatus::Unloaded;
resolve_only(&mut entry, &def.file_path, base_dir);
} else {
stat_into(&mut entry, &def.file_path, base_dir);
entry.status = decide_status(
entry.status,
entry.modified,
prev.get(&key).copied(),
false,
);
if !referenced_underlays.contains(handle) {
entry.status = RefStatus::Unreferenced;
}
}
entries.push(entry);
}
// ── Nested enumeration: full transitive closure, read-only, no merge ──
// Paths stored in a nested drawing are relative to *that drawing*, not to
// the host. The work queue carries each parent's resolved file so every
// level gets the correct base directory. `seen` is keyed by resolved
// absolute identity and breaks A→B→A cycles as well as shared closures.
let mut seen: HashSet<String> = HashSet::default();
let mut work: Vec<(u64, PathBuf)> = Vec::new();
for e in &entries {
if e.kind == RefKind::DwgXref && matches!(e.status, RefStatus::Loaded | RefStatus::Stale) {
if let Some(found) = &e.found_at {
let path = PathBuf::from(found);
seen.insert(normalize_lexical(&path.to_string_lossy()));
work.push((e.key, path));
}
}
}
let mut nested_cache: HashMap<String, CadDocument> = HashMap::default();
while let Some((parent_key, parent_path)) = work.pop() {
let cache_key = normalize_lexical(&parent_path.to_string_lossy());
let nested_doc = if let Some(cached) = nested_cache.get(&cache_key) {
cached.clone()
} else {
match super::load_file(&parent_path) {
Ok(nested) => {
nested_cache.insert(cache_key, nested.clone());
nested
}
Err(_) => continue,
}
};
let parent_dir = parent_path.parent().unwrap_or(base_dir);
for br in nested_doc.block_records.iter() {
if !(br.flags.is_xref || br.flags.is_xref_overlay) {
continue;
}
let key = child_key(parent_key, &br.name, &br.xref_path);
let mut child = ReferenceEntry::new(key, br.name.clone(), RefKind::DwgXref);
child.parent_key = Some(parent_key);
child.ref_type = if br.flags.is_xref_overlay { RefType::Overlay } else { RefType::Attach };
child.saved_path = br.xref_path.clone();
if unloaded.contains(&UnloadKey::Nested(key)) {
child.status = RefStatus::Unloaded;
} else {
stat_into(&mut child, &br.xref_path, parent_dir);
// Nested baselines live in the same stat cache under the
// child's high-bit-tagged key, which can never alias a host
// handle (see `child_key`) — so the lookup below is safe.
child.status = decide_status(child.status, child.modified, prev.get(&key).copied(), false);
}
if let Some(found) = &child.found_at {
let resolved_id = normalize_lexical(found);
if seen.insert(resolved_id) {
work.push((key, PathBuf::from(found)));
}
}
entries.push(child);
}
}
// Stale propagation (plan §3.1): a Loaded parent with a Stale descendant
// shows Stale. Roots append before their children, so one reverse pass
// carries staleness transitively up any depth: by the time the iteration
// reaches a parent, the parent already reflects its own subtree.
// Unloaded/NotFound/Failed rows are never overridden.
{
let index: HashMap<u64, usize> =
entries.iter().enumerate().map(|(i, e)| (e.key, i)).collect();
for rev in (0..entries.len()).rev() {
if entries[rev].status != RefStatus::Stale {
continue;
}
if let Some(pk) = entries[rev].parent_key {
if let Some(&pi) = index.get(&pk) {
if entries[pi].status == RefStatus::Loaded {
entries[pi].status = RefStatus::Stale;
}
}
}
}
}
entries
}
/// Direct (host-owned) reference target for a top-level key.
///
/// Nested child keys are stable hashes, never handle values, so they resolve
/// to `None` here — callers treat that as "not directly actionable".
enum RefTarget {
DwgXref { handle: Handle, name: String },
Image { handle: Handle, name: String },
Pdf { handle: Handle, name: String },
}
fn find_target(doc: &CadDocument, key: u64) -> Option<RefTarget> {
for br in doc.block_records.iter() {
if (br.flags.is_xref || br.flags.is_xref_overlay) && br.handle.value() == key {
return Some(RefTarget::DwgXref {
handle: br.handle,
name: br.name.clone(),
});
}
}
for (handle, obj) in doc.objects.iter() {
use codec::objects::ObjectType;
match obj {
ObjectType::ImageDefinition(def) if handle.value() == key => {
return Some(RefTarget::Image {
handle: *handle,
name: def.file_name.clone(),
});
}
ObjectType::UnderlayDefinition(def) if handle.value() == key => {
return Some(RefTarget::Pdf {
handle: *handle,
name: if def.name.trim().is_empty() {
def.file_path.clone()
} else {
def.name.clone()
},
});
}
_ => {}
}
}
None
}
/// Drop merged content for one xref without removing its definition.
///
/// Deletes every entity whose `owner_handle` is the xref BlockRecord handle
/// plus that block's draw-order tables; the BlockRecord (flags, path) stays.
/// Image/PDF keys need no doc mutation (session-unloaded covers them) and
/// return `Ok`. Unknown (nested-hash) keys error — nested rows are never
/// directly unloadable.
pub fn unload_reference(doc: &mut CadDocument, key: u64) -> Result<String, String> {
let Some(target) = find_target(doc, key) else {
return Err(crate::t!("XREF: no loaded reference with that key.").to_string());
};
match target {
RefTarget::DwgXref { handle, name } => {
let owned: Vec<Handle> = doc
.entities()
.filter(|e| e.common().owner_handle == handle)
.map(|e| e.common().handle)
.collect();
let owned_set: HashSet<Handle> = owned.iter().copied().collect();
for h in owned {
doc.remove_entity(h);
}
// Prune the membership chain so the block reads back empty.
if let Some(br) = doc.block_records.iter_mut().find(|b| b.handle == handle) {
br.entity_handles.retain(|h| !owned_set.contains(h));
}
// Drop draw-order overrides owned by the unloaded block.
{
use codec::objects::ObjectType;
let dead: Vec<Handle> = doc
.objects
.iter()
.filter_map(|(h, o)| match o {
ObjectType::SortEntitiesTable(t)
if t.block_owner_handle == handle =>
{
Some(*h)
}
_ => None,
})
.collect();
for h in dead {
doc.objects.remove(&h);
}
}
Ok(name)
}
RefTarget::Image { name, .. } => Ok(name),
// A PDF keeps its unloaded state on the definition (saved with it).
RefTarget::Pdf { handle, name } => {
if let Some(codec::objects::ObjectType::UnderlayDefinition(def)) =
doc.objects.get_mut(&handle)
{
def.unloaded = true;
}
Ok(name)
}
}
}
/// Detach one direct reference: erase all its instances, delete the
/// BlockRecord/definition and this xref's `name|*` dependent symbols.
///
/// - Nested keys (stable hashes, no direct definition) have no `find_target`
/// hit → `Err`. The CLI maps entries with `parent_key.is_some()` to the
/// "cannot detach nested" message with the entry name.
/// - INSERT erase walks the flat store, which holds block-owned entities too
/// (SPIKE2 item 6), so instances inside other block definitions are covered.
/// - Images/PDFs: remove the definition object plus every entity referencing
/// it by handle.
pub fn detach_reference(doc: &mut CadDocument, key: u64) -> Result<String, String> {
let Some(target) = find_target(doc, key) else {
return Err(crate::t!("XREF: no loaded reference with that key.").to_string());
};
match target {
RefTarget::DwgXref { handle, name } => {
// (b) erase ALL INSERT instances (flat store includes block-owned).
let inserts: Vec<Handle> = doc
.entities()
.filter_map(|e| match e {
EntityType::Insert(ins)
if ins.block_name.eq_ignore_ascii_case(&name) =>
{
Some(e.common().handle)
}
_ => None,
})
.collect();
for h in inserts {
doc.remove_entity(h);
}
// Merged content owned by the xref block.
let owned: Vec<Handle> = doc
.entities()
.filter(|e| e.common().owner_handle == handle)
.map(|e| e.common().handle)
.collect();
for h in owned {
doc.remove_entity(h);
}
// (c) `name|*` dependents for THIS xref only (case-insensitive).
remove_pipe_symbols(doc, &name);
// Draw-order tables owned by the detached block.
{
use codec::objects::ObjectType;
let dead: Vec<Handle> = doc
.objects
.iter()
.filter_map(|(h, o)| match o {
ObjectType::SortEntitiesTable(t)
if t.block_owner_handle == handle =>
{
Some(*h)
}
_ => None,
})
.collect();
for h in dead {
doc.objects.remove(&h);
}
}
doc.block_records.remove(&name);
Ok(name)
}
RefTarget::Image { handle, name } => {
let refs: Vec<Handle> = doc
.entities()
.filter_map(|e| match e {
EntityType::RasterImage(img)
if img.definition_handle == Some(handle) =>
{
Some(e.common().handle)
}
_ => None,
})
.collect();
for h in refs {
doc.remove_entity(h);
}
doc.objects.remove(&handle);
Ok(name)
}
RefTarget::Pdf { handle, name } => {
let refs: Vec<Handle> = doc
.entities()
.filter_map(|e| match e {
EntityType::Underlay(ul) if ul.definition_handle == handle => {
Some(e.common().handle)
}
_ => None,
})
.collect();
for h in refs {
doc.remove_entity(h);
}
doc.objects.remove(&handle);
Ok(name)
}
}
}
// ── BIND (Task 9) ─────────────────────────────────────────────────────────
/// Outcome of binding one reference.
#[derive(Debug)]
pub struct BindOutcome {
/// Bound block / definition name.
pub name: String,
/// Foreign style handles that could not be remapped (see
/// `bind_limitations` on the shared remap helper).
pub unremapped: usize,
}
/// Bind one direct reference: fold the external file into the host drawing.
///
/// - DWG xrefs become plain local blocks (same name, so existing INSERTs keep
/// resolving). Dependent symbols `name|sym` become `name$0$sym` (N bumped
/// past collisions); nested children bind first so chains compose
/// transitively (`PLAN$0$DETAIL$0$WALLS`); the whole bind-closure is
/// merged — nested geometry is never dropped.
/// - Images are COLLECTed: the file is copied next to the host drawing and
/// the definition retargeted to the relative filename. A copy failure
/// errors before the host doc is touched (no partial state).
/// - PDFs error explicitly — vector import is unavailable, and silently
/// dropping them would lose data.
/// - Nested keys have no host definition and never reach here (callers map
/// them to the "bind it in its host drawing" message, mirroring Detach).
pub fn bind_reference(
doc: &mut CadDocument,
key: u64,
base_dir: &Path,
host_dir: &Path,
) -> Result<BindOutcome, String> {
let Some(target) = find_target(doc, key) else {
return Err(crate::t!("XREF: no loaded reference with that key.").to_string());
};
match target {
RefTarget::DwgXref { handle, name } => bind_dwg(doc, handle, &name, base_dir, host_dir),
RefTarget::Image { handle, name } => {
collect_bind_images(doc, &name, base_dir, host_dir, Some(handle))?;
Ok(BindOutcome { name, unremapped: 0 })
}
RefTarget::Pdf { .. } => Err(crate::t!(
"XREF: PDF bind (vector import) is not available in this version."
)
.to_string()),
}
}
fn bind_failed(name: &str, reason: impl std::fmt::Display) -> String {
crate::tf!("XREF: cannot bind \"{}\": {}.", name, reason.to_string()).to_string()
}
fn bind_dwg(
doc: &mut CadDocument,
br_handle: Handle,
name: &str,
base_dir: &Path,
host_dir: &Path,
) -> Result<BindOutcome, String> {
let saved = doc
.block_records
.iter()
.find(|b| b.handle == br_handle)
.map(|b| b.xref_path.clone())
.unwrap_or_default();
let Some(found) = resolve_path(&saved, base_dir) else {
return Err(bind_failed(name, "file not found"));
};
let source_dir = found
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
// Load the closure (this file + every nested xref, children first). All
// file copies happen below, before the host doc is touched — a copy or
// parse failure leaves no partial document state.
let mut source = load_bind_source(name, &found)?;
check_no_pdf_underlays(&source)?;
let mut stack = vec![crate::io::xref_model::normalize_lexical(
&found.to_string_lossy(),
)];
let mut unremapped = bind_nested_closure(&mut source, &source_dir, host_dir, &mut stack)?;
collect_bind_images(&mut source, name, &source_dir, host_dir, None)?;
// Drop the previous merge + `name|*` symbols. INSERTs of the xref stay —
// they now reference the bound block.
unload_reference(doc, br_handle.value())?;
remove_pipe_symbols(doc, name);
let taken = BindTaken::capture(doc);
let (u, _) = merge_bound_doc(doc, name, br_handle, &source, taken);
unremapped += u;
// Convert the xref BlockRecord into a plain local block (same name, so
// existing INSERTs keep resolving).
if let Some(br) = doc.block_records.iter_mut().find(|b| b.handle == br_handle) {
br.flags.is_xref = false;
br.flags.is_xref_overlay = false;
br.xref_path.clear();
}
Ok(BindOutcome {
name: name.to_string(),
unremapped,
})
}
fn load_bind_source(ref_name: &str, found: &Path) -> Result<CadDocument, String> {
match super::load_file(found) {
Ok(doc) => Ok(doc),
Err(e) => Err(bind_failed(ref_name, e)),
}
}
/// Reject PDF underlays anywhere in a bind closure: vector import is
/// unavailable, and silently dropping them would lose data.
fn check_no_pdf_underlays(source: &CadDocument) -> Result<(), String> {
use codec::entities::UnderlayType;
use codec::objects::ObjectType;
let has_pdf = source.objects.values().any(|o| {
matches!(o, ObjectType::UnderlayDefinition(d) if d.underlay_type == UnderlayType::Pdf)
});
if has_pdf {
return Err(crate::t!(
"XREF: PDF bind (vector import) is not available in this version."
)
.to_string());
}
Ok(())
}
/// Recursively bind every nested xref inside `source`, children first, so the
/// parent merge composes transitive chains (`PLAN$0$DETAIL$0$WALLS`).
/// Unresolvable nested files error the whole bind; cyclic closures enumerate
/// once (mirroring `collect_entries`). Returns the nested unremapped count.
fn bind_nested_closure(
source: &mut CadDocument,
source_dir: &Path,
host_dir: &Path,
stack: &mut Vec<String>,
) -> Result<usize, String> {
let children: Vec<(String, Handle, String)> = source
.block_records
.iter()
.filter(|br| (br.flags.is_xref || br.flags.is_xref_overlay) && !br.xref_path.is_empty())
.map(|br| (br.name.clone(), br.handle, br.xref_path.clone()))
.collect();
let mut taken = BindTaken::capture(source);
let mut unremapped = 0usize;
for (child_name, child_br, child_saved) in children {
let Some(child_found) = resolve_path(&child_saved, source_dir) else {
return Err(bind_failed(&child_name, "file not found"));
};
let norm =
crate::io::xref_model::normalize_lexical(&child_found.to_string_lossy());
if stack.contains(&norm) {
continue;
}
stack.push(norm);
let mut child_doc = load_bind_source(&child_name, &child_found)?;
let child_dir = child_found
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
unremapped += bind_nested_closure(&mut child_doc, &child_dir, host_dir, stack)?;
stack.pop();
check_no_pdf_underlays(&child_doc)?;
collect_bind_images(&mut child_doc, &child_name, &child_dir, host_dir, None)?;
let (u, taken_back) = merge_bound_doc(source, &child_name, child_br, &child_doc, taken);
unremapped += u;
taken = taken_back;
if let Some(br) = source
.block_records
.iter_mut()
.find(|b| b.handle == child_br)
{
br.flags.is_xref = false;
br.flags.is_xref_overlay = false;
br.xref_path.clear();
}
}
Ok(unremapped)
}
/// Merge one fully child-bound source doc into `target` under BIND names
/// (`{parent}$N${sym}`), threading `taken` through for collision dedup.
/// Returns the merge's unremapped count plus the updated taken sets.
fn merge_bound_doc(
target: &mut CadDocument,
parent: &str,
target_br: Handle,
source: &CadDocument,
taken: BindTaken,
) -> (usize, BindTaken) {
ensure_block_entities(target, parent);
let mut naming = SymbolNaming::BindDollar {
parent: parent.to_string(),
taken,
};
let maps = import_xref_symbols(target, source, &mut naming);
let (_, unremapped, entity_handle_map) =
merge_source_entities(target, source, target_br, &maps);
copy_xref_sortents(
target,
source,
target_br,
&maps.br_handles,
&entity_handle_map,
);
let taken = match naming {
SymbolNaming::BindDollar { taken, .. } => taken,
SymbolNaming::MergePipe { .. } => unreachable!("bind merge always uses BindDollar"),
};
(unremapped, taken)
}
/// COLLECT every raster image in `source`: copy the file next to the host
/// drawing (first claimant keeps the filename, later same-name collisions get
/// `stem$N.ext`) and retarget definitions + entity paths to the relative
/// filename. Copies run before any host mutation — a failure errors with no
/// partial document state. `only` restricts the pass to one definition
/// (direct image bind); `None` collects the whole closure.
fn collect_bind_images(
source: &mut CadDocument,
ref_name: &str,
source_dir: &Path,
host_dir: &Path,
only: Option<Handle>,
) -> Result<(), String> {
use codec::objects::ObjectType;
let defs: Vec<(Handle, String)> = source
.objects
.iter()
.filter_map(|(h, o)| match o {
ObjectType::ImageDefinition(d)
if only.is_none_or(|want| want == *h) =>
{
Some((*h, d.file_name.clone()))
}
_ => None,
})
.collect();
if defs.is_empty() {
return Ok(());
}
// Plan every copy first (dedupe repeat references to one source file).
let mut planned: HashMap<String, String> = HashMap::default();
let mut ops: Vec<(Handle, PathBuf, String)> = Vec::new();
for (handle, saved) in &defs {
let Some(resolved) = resolve_path(saved, source_dir) else {
return Err(bind_failed(ref_name, format!("image not found: {saved}")));
};
let norm = crate::io::xref_model::normalize_lexical(&resolved.to_string_lossy());
let dest_name = if let Some(prior) = planned.get(&norm) {
prior.clone()
} else {
let base = file_name_only(saved);
let mut candidate = base.clone();
let mut n = 0u32;
while host_dir.join(&candidate).exists()
&& !same_file(&resolved, &host_dir.join(&candidate))
{
n += 1;
candidate = unique_file_name(&base, n);
}
planned.insert(norm, candidate.clone());
candidate
};
ops.push((*handle, resolved, dest_name));
}
for (_, src, dest_name) in &ops {
let dest = host_dir.join(dest_name);
if same_file(src, &dest) {
continue;
}
if let Err(e) = std::fs::copy(src, &dest) {
return Err(bind_failed(ref_name, format!("cannot collect image: {e}")));
}
}
// Retarget definitions + entity paths to the collected relative names.
for (handle, _, dest_name) in ops {
if let Some(ObjectType::ImageDefinition(def)) = source.objects.get_mut(&handle) {
def.file_name = dest_name.clone();
}
let owned: Vec<Handle> = source
.entities()
.filter_map(|e| match e {
EntityType::RasterImage(img) if img.definition_handle == Some(handle) => {
Some(e.common().handle)
}
_ => None,
})
.collect();
for eh in owned {
if let Some(EntityType::RasterImage(img)) = source.get_entity_mut(eh) {
img.file_path = dest_name.clone();
}
}
}
Ok(())
}
fn file_name_only(raw: &str) -> String {
raw.rsplit(['/', '\\']).next().unwrap_or(raw).to_string()
}
fn unique_file_name(base: &str, n: u32) -> String {
match base.rfind('.') {
Some(i) if i > 0 => format!("{}${n}.{}", &base[..i], &base[i + 1..]),
_ => format!("{base}${n}"),
}
}
fn same_file(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(x), Ok(y)) => x == y,
_ => a == b,
}
}
/// Make the named references' paths relative to `host` (their drawing's
/// first save); a reference that cannot be relative keeps its full path.
pub fn make_relative(
doc: &mut CadDocument,
names: &rustc_hash::FxHashSet<String>,
host: &Path,
) {
for name in names {
let Some(key) = doc.block_records.get(name).map(|br| br.handle.value()) else {
continue;
};
let _ = apply_pathtype(doc, key, crate::io::xref_model::Pathtype::Relative, host);
}
}
/// Drop from a save copy what resolving each xref merged into the host but
/// the file never stores: the reference's own geometry, its nested block
/// definitions and its text and dimension styles. Dependent layers and
/// linetypes stay (flagged), so layer overrides kept under VISRETAIN persist.
pub fn strip_resolved_xref_content(doc: &mut CadDocument) {
let xrefs: Vec<(String, Handle)> = doc
.block_records
.iter()
.filter(|br| br.flags.is_xref || br.flags.is_xref_overlay)
.map(|br| (br.name.clone(), br.handle))
.collect();
for (name, handle) in xrefs {
let (begin, end) = doc
.block_records
.get(&name)
.map(|br| (br.block_entity_handle, br.block_end_handle))
.unwrap_or((Handle::NULL, Handle::NULL));
let owned: Vec<Handle> = doc
.entities()
.filter(|e| e.common().owner_handle == handle)
.map(|e| e.common().handle)
.filter(|h| *h != begin && *h != end)
.collect();
for h in owned {
doc.remove_entity(h);
}
if let Some(br) = doc.block_records.get_mut(&name) {
br.entity_handles.clear();
}
remove_dependent_symbols(doc, &name, false);
}
}
/// Text styles a kept linetype draws its shapes or text with.
fn linetype_styles(doc: &CadDocument) -> HashSet<Handle> {
doc.line_types
.iter()
.flat_map(|linetype| linetype.elements.iter())
.filter_map(|element| element.complex.as_ref().map(|complex| complex.style_handle))
.filter(|handle| !handle.is_null())
.collect()
}
/// Remove one xref's `name|*` dependent symbols (case-insensitive): layers,
/// linetypes, text styles, dim styles, and nested block records — plus every
/// entity and draw-order table owned by those dependent blocks.
///
/// Shared by Detach (which then also erases the BlockRecord itself) and BIND
/// (which drops the previous merge before re-merging under `$N$` names). The
/// xref BlockRecord itself, its INSERTs, and non-prefixed symbols stay.
fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) {
remove_dependent_symbols(doc, name, true);
}
fn remove_dependent_symbols(doc: &mut CadDocument, name: &str, layers_too: bool) {
let prefix = format!("{}|", name.to_uppercase());
let is_dep = |n: &str| n.to_uppercase().starts_with(&prefix);
// Dependent blocks first — their handles route the owned-entity cleanup.
let dep_brs: HashSet<Handle> = doc
.block_records
.iter()
.filter(|b| is_dep(&b.name))
.map(|b| b.handle)
.collect();
let owned: Vec<Handle> = doc
.entities()
.filter(|e| dep_brs.contains(&e.common().owner_handle))
.map(|e| e.common().handle)
.collect();
for h in owned {
doc.remove_entity(h);
}
{
use codec::objects::ObjectType;
let dead: Vec<Handle> = doc
.objects
.iter()
.filter_map(|(h, o)| match o {
ObjectType::SortEntitiesTable(t) if dep_brs.contains(&t.block_owner_handle) => {
Some(*h)
}
_ => None,
})
.collect();
for h in dead {
doc.objects.remove(&h);
}
}
let layers: Vec<String> = doc
.layers
.names()
.filter(|n| layers_too && is_dep(n))
.map(|s| s.to_string())
.collect();
for n in layers {
doc.layers.remove(&n);
}
let lts: Vec<String> = doc
.line_types
.names()
.filter(|n| layers_too && is_dep(n))
.map(|s| s.to_string())
.collect();
for n in lts {
doc.line_types.remove(&n);
}
// Styles a kept dependent linetype still draws with stay, flagged.
let used = if layers_too {
HashSet::default()
} else {
linetype_styles(doc)
};
let styles: Vec<String> = doc
.text_styles
.iter()
.filter(|style| is_dep(&style.name) && !used.contains(&style.handle))
.map(|style| style.name.clone())
.collect();
for n in styles {
doc.text_styles.remove(&n);
}
let owner = doc
.block_records
.get(name)
.map(|br| br.handle)
.unwrap_or(Handle::NULL);
for style in doc.text_styles.iter_mut() {
if is_dep(&style.name) {
style.xref_dependent = true;
style.xref_block_record_handle = owner;
}
}
let dims: Vec<String> = doc
.dim_styles
.names()
.filter(|n| is_dep(n))
.map(|s| s.to_string())
.collect();
for n in dims {
doc.dim_styles.remove(&n);
}
let brs: Vec<String> = doc
.block_records
.iter()
.map(|b| b.name.clone())
.filter(|n| is_dep(n))
.collect();
for n in brs {
doc.block_records.remove(&n);
}
}
/// Snapshot one xref's dependent-layer overrides for VISRETAIN=1 reloads.
///
/// Returns `name|*` layers keyed by uppercased name. The reload path purges
/// these entries and re-merges file defaults; without the snapshot the
/// user's frozen/off/color tweaks would be silently lost on every reload.
fn snapshot_dependent_layers(
doc: &CadDocument,
name: &str,
) -> HashMap<String, codec::tables::Layer> {
let prefix = format!("{}|", name.to_uppercase());
doc.layers
.names()
.filter(|n| n.to_uppercase().starts_with(&prefix))
.filter_map(|n| {
doc.layers
.get(n)
.cloned()
.map(|l| (l.name.to_uppercase(), l))
})
.collect()
}
/// Re-apply a [`snapshot_dependent_layers`] snapshot onto freshly merged
/// layers. Only layers present post-merge are touched (deleted-in-file layers
/// stay deleted); handles and file-structural links (material, plotstyle,
/// xref block-record pointer) stay on the fresh entries — everything a user
/// can tweak in a layer UI is restored.
fn restore_dependent_layers(
doc: &mut CadDocument,
name: &str,
kept: HashMap<String, codec::tables::Layer>,
) {
if kept.is_empty() {
return;
}
let prefix = format!("{}|", name.to_uppercase());
let targets: Vec<String> = doc
.layers
.names()
.filter(|n| n.to_uppercase().starts_with(&prefix))
.map(|s| s.to_string())
.collect();
for n in targets {
let (Some(cur), Some(old)) = (
doc.layers.get_mut(&n),
kept.get(&n.to_uppercase()),
) else {
continue;
};
cur.flags = old.flags.clone();
cur.color = old.color.clone();
cur.color_name = old.color_name.clone();
cur.book_name = old.book_name.clone();
cur.line_type = old.line_type.clone();
cur.line_weight = old.line_weight.clone();
cur.plot_style = old.plot_style.clone();
cur.is_plottable = old.is_plottable;
cur.transparency = old.transparency.clone();
}
}
/// Store `new_raw` verbatim on the definition (no synthesis).
pub fn set_ref_path(
doc: &mut CadDocument,
key: u64,
new_raw: &str,
) -> Result<String, String> {
let Some(target) = find_target(doc, key) else {
return Err(crate::t!("XREF: no loaded reference with that key.").to_string());
};
match target {
RefTarget::DwgXref { name, .. } => {
if let Some(br) = doc.block_records.get_mut(&name) {
br.xref_path = new_raw.to_string();
return Ok(name);
}
Err(crate::t!("XREF: no loaded reference with that key.").to_string())
}
RefTarget::Image { handle, name } => {
use codec::objects::ObjectType;
match doc.objects.get_mut(&handle) {
Some(ObjectType::ImageDefinition(def)) => {
def.file_name = new_raw.to_string();
Ok(name)
}
_ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()),
}
}
RefTarget::Pdf { handle, name } => {
use codec::objects::ObjectType;
match doc.objects.get_mut(&handle) {
Some(ObjectType::UnderlayDefinition(def)) => {
def.file_path = new_raw.to_string();
Ok(name)
}
_ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()),
}
}
}
}
/// Convert one reference's stored path to `pathtype` against `host_path`.
/// Verbatim store on success; neutral errors otherwise.
pub fn apply_pathtype(
doc: &mut CadDocument,
key: u64,
pathtype: crate::io::xref_model::Pathtype,
host_path: &Path,
) -> Result<String, String> {
let saved = match find_target(doc, key) {
Some(RefTarget::DwgXref { name, .. }) => doc
.block_records
.get(&name)
.map(|b| b.xref_path.clone())
.unwrap_or_default(),
Some(RefTarget::Image { handle, .. }) => {
use codec::objects::ObjectType;
match doc.objects.get(&handle) {
Some(ObjectType::ImageDefinition(def)) => def.file_name.clone(),
_ => String::new(),
}
}
Some(RefTarget::Pdf { handle, .. }) => {
use codec::objects::ObjectType;
match doc.objects.get(&handle) {
Some(ObjectType::UnderlayDefinition(def)) => def.file_path.clone(),
_ => String::new(),
}
}
None => return Err(crate::t!("XREF: no loaded reference with that key.").to_string()),
};
// A stored relative path first has to be resolved against its current host
// folder. Passing it straight to `to_pathtype_result` made `Full` a
// no-op (`refs/a.dwg` stayed relative instead of becoming C:/…/refs/a).
let normalized = crate::io::xref_model::normalize_lexical(&saved);
let bytes = normalized.as_bytes();
let looks_absolute = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
|| normalized.starts_with("//")
|| normalized.starts_with('/');
let stored_relative = !looks_absolute;
let effective = if stored_relative {
host_path
.parent()
.map(|dir| dir.join(&saved).to_string_lossy().into_owned())
.unwrap_or(saved.clone())
} else {
saved
};
match crate::io::xref_model::to_pathtype_result(&effective, host_path, pathtype) {
Ok(next) => {
set_ref_path(doc, key, &next)?;
Ok(next)
}
Err(crate::io::xref_model::PathtypeError::AcrossDrives) => {
Err(crate::t!("XREF: cannot make path relative across drives.").to_string())
}
Err(crate::io::xref_model::PathtypeError::UnsavedHost) => Err(
crate::t!("XREF Save the drawing first to resolve relative XREF paths.").to_string(),
),
}
}
/// Find & Replace: rewrite `old` → `new` as a component-wise prefix replace
/// wherever the normalized saved path has the normalized `old` path as its
/// leading run of `/`-components. Returns rewrites.
///
/// Component-wise (never a raw byte splice): matching happens on normalized
/// forms and the result is rejoined from normalized components, so `..`-laden
/// or case-folded saved paths rewrite correctly and multibyte paths can never
/// panic on a mid-char cut.
pub fn replace_path_prefix(doc: &mut CadDocument, old: &str, new: &str) -> usize {
use crate::io::xref_model::normalize_lexical;
let old_n = normalize_lexical(old);
if old_n.is_empty() {
return 0;
}
let old_comps: Vec<&str> = old_n.split('/').collect();
let new_n = normalize_lexical(new);
let splice = |saved: &mut String| -> bool {
let saved_n = normalize_lexical(saved);
let saved_comps: Vec<&str> = saved_n.split('/').collect();
if saved_comps.len() < old_comps.len() {
return false;
}
if saved_comps[..old_comps.len()] != old_comps[..] {
return false;
}
let rest = &saved_comps[old_comps.len()..];
let mut out = new_n.clone();
if !rest.is_empty() {
if !out.is_empty() {
out.push('/');
}
out.push_str(&rest.join("/"));
}
*saved = out;
true
};
let mut n = 0usize;
for br in doc.block_records.iter_mut() {
if !(br.flags.is_xref || br.flags.is_xref_overlay) || br.xref_path.is_empty() {
continue;
}
if splice(&mut br.xref_path) {
n += 1;
}
}
{
use codec::objects::ObjectType;
for obj in doc.objects.values_mut() {
match obj {
ObjectType::ImageDefinition(def) if !def.file_name.is_empty() => {
if splice(&mut def.file_name) {
n += 1;
}
}
ObjectType::UnderlayDefinition(def) if !def.file_path.is_empty() => {
if splice(&mut def.file_path) {
n += 1;
}
}
_ => {}
}
}
}
n
}
/// Flip Attach ↔ Overlay for one DWG xref. DWG only; images/PDFs error.
///
/// The flip affects future nesting propagation (overlays do not re-export
/// through the host); current geometry is untouched.
pub fn set_ref_type(
doc: &mut CadDocument,
key: u64,
ref_type: crate::io::xref_model::RefType,
) -> Result<String, String> {
let Some(target) = find_target(doc, key) else {
return Err(crate::t!("XREF: no loaded reference with that key.").to_string());
};
match target {
RefTarget::DwgXref { name, .. } => {
if let Some(br) = doc.block_records.get_mut(&name) {
match ref_type {
crate::io::xref_model::RefType::Attach => {
br.flags.is_xref = true;
br.flags.is_xref_overlay = false;
}
crate::io::xref_model::RefType::Overlay => {
br.flags.is_xref = false;
br.flags.is_xref_overlay = true;
}
}
return Ok(name);
}
Err(crate::t!("XREF: no loaded reference with that key.").to_string())
}
RefTarget::Image { .. } | RefTarget::Pdf { .. } => {
Err(crate::t!("XREF: overlays apply to drawing references only.").to_string())
}
}
}
/// Save-As rebase helper, wired in `queue_native_save` (Task 8b).
///
/// Call with the old/new parent dirs before serializing: every relative
/// saved path is made absolute via `old_base` then re-expressed relative to
/// `new_base` (absolute paths and cross-drive targets are left absolute).
/// Returns rewrite count.
///
/// Both the save snapshot and, on Save-As completion (`on_save_finished`),
/// the live document are rebased so the running session agrees with the file
/// just written.
pub fn rebase_relative_paths_for_save_as(
doc: &mut CadDocument,
old_base: &Path,
new_base: &Path,
) -> usize {
use crate::io::xref_model::{normalize_lexical, to_pathtype_result, Pathtype};
let is_relative = |s: &str| {
let n = normalize_lexical(s);
if n.is_empty() {
return false;
}
let b = n.as_bytes();
if b.len() >= 2 && b[0].is_ascii_alphabetic() && b[1] == b':' {
return false;
}
if n.starts_with("//") || n.starts_with('/') {
return false;
}
true
};
// Fake host file inside the new base so `to_pathtype_result` has a parent.
let new_host = new_base.join("__host__.dwg");
let mut n = 0usize;
let mut rebase = |saved: &mut String| {
if !is_relative(saved) {
return;
}
let abs = old_base.join(saved.clone());
let abs_s = abs.to_string_lossy().into_owned();
if let Ok(rel) = to_pathtype_result(&abs_s, &new_host, Pathtype::Relative) {
*saved = rel;
n += 1;
}
};
for br in doc.block_records.iter_mut() {
if !(br.flags.is_xref || br.flags.is_xref_overlay) || br.xref_path.is_empty() {
continue;
}
rebase(&mut br.xref_path);
}
{
use codec::objects::ObjectType;
for obj in doc.objects.values_mut() {
match obj {
ObjectType::ImageDefinition(def) if !def.file_name.is_empty() => {
rebase(&mut def.file_name)
}
ObjectType::UnderlayDefinition(def) if !def.file_path.is_empty() => {
rebase(&mut def.file_path)
}
_ => {}
}
}
}
n
}
#[cfg(test)]
mod tests {
use super::collect_entries;
use super::collect_entries_with_prev;
use super::HashSet;
use super::{
apply_pathtype, child_key, detach_reference, replace_path_prefix,
resolve_xrefs_for_keys, restore_dependent_layers, set_ref_path,
set_ref_type, snapshot_dependent_layers, unload_reference,
};
use crate::io::xref_model::{Pathtype, RefKind, RefStatus, RefType};
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn collect_single_xref_loaded_with_verbatim_path() {
let tmp = std::env::temp_dir().join(format!(
"ocs_xref_collect_{}_{}.dwg",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&tmp, b"").unwrap();
let saved = tmp.to_string_lossy().into_owned();
let mut doc = codec::CadDocument::new();
let mut br = codec::tables::BlockRecord::new("PLAN");
br.flags.is_xref = true;
br.xref_path = saved.clone();
br.handle = doc.allocate_handle();
doc.block_records.add(br).expect("add xref block record");
let base = tmp.parent().unwrap();
let entries = collect_entries(&doc, base, &std::collections::HashSet::new());
std::fs::remove_file(&tmp).ok();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].status, RefStatus::Loaded);
assert_eq!(entries[0].saved_path, saved);
assert_eq!(entries[0].kind, RefKind::DwgXref);
}
fn xref_doc_with(name: &str, saved: &str) -> (codec::CadDocument, u64) {
let mut doc = codec::CadDocument::new();
let mut br = codec::tables::BlockRecord::new(name);
br.flags.is_xref = true;
br.xref_path = saved.to_string();
br.handle = doc.allocate_handle();
let key = br.handle.value();
doc.block_records.add(br).expect("add xref");
(doc, key)
}
fn xref_tmpdir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"ocs_xref_{tag}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_nested_fixture(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
// mid.dwgxrefs inner.dwg; both must parse (enumeration reads the
// mid file's block-record table). Returns (mid, inner) abs paths.
std::fs::write(dir.join("inner.dwg"), b"inner-bytes").unwrap();
let mut mid = codec::CadDocument::new();
let mut br = codec::tables::BlockRecord::new("INNER");
br.flags.is_xref = true;
br.xref_path = dir.join("inner.dwg").to_string_lossy().into_owned();
mid.block_records.add(br).unwrap();
let bytes = crate::io::save_to_bytes(&mid, "dwg", mid.version).unwrap();
std::fs::write(dir.join("mid.dwg"), &bytes).unwrap();
(dir.join("mid.dwg"), dir.join("inner.dwg"))
}
#[test]
fn nested_stale_propagates_to_parent() {
// Plan §3.1: a Loaded parent with a Stale nested descendant shows
// Stale. The nested baseline predates the live mtime (epoch), so the
// child is Stale and the reverse pass carries it to the parent —
// without touching Unloaded/Failed rows.
use std::time::UNIX_EPOCH;
let dir = xref_tmpdir("staleprop");
let (mid_path, inner_path) = write_nested_fixture(&dir);
let (doc, mid_key) = xref_doc_with(
"MID",
&mid_path.to_string_lossy(),
);
let inner_key = child_key(
mid_key,
"INNER",
&inner_path.to_string_lossy(),
);
let mut prev = std::collections::HashMap::new();
prev.insert(inner_key, UNIX_EPOCH);
let entries = collect_entries_with_prev(
&doc,
&dir,
&std::collections::HashSet::new(),
&prev,
);
let inner = entries.iter().find(|e| e.key == inner_key).expect("nested INNER listed");
assert_eq!(inner.status, RefStatus::Stale);
let mid = entries.iter().find(|e| e.key == mid_key).expect("MID listed");
assert_eq!(mid.status, RefStatus::Stale, "parent reflects stale child");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn xref_end_to_end_engine_lifecycle() {
// One true integration pass over a real DWG fixture (nested xref +
// referenced image + unreferenced PDF): attach-equivalent list →
// unload → targeted reload → detach. Catches integration skew that
// unit tests on single ops cannot.
use codec::objects::{ImageDefinition, ObjectType, UnderlayDefinition};
let dir = xref_tmpdir("e2e");
let (mid_path, _) = write_nested_fixture(&dir);
std::fs::write(dir.join("img.png"), b"fake-png").unwrap();
let (mut doc, mid_key) =
xref_doc_with("MID", &mid_path.to_string_lossy());
// Referenced image → Loaded entry.
let img_h = doc.allocate_handle();
let mut img_def = ImageDefinition::with_dimensions("img.png", 8, 8);
img_def.handle = img_h;
doc.objects.insert(img_h, ObjectType::ImageDefinition(img_def));
let mut img = codec::entities::RasterImage::new(
"img.png",
codec::types::Vector3::ZERO,
8.0,
8.0,
);
img.definition_handle = Some(img_h);
doc.add_entity(codec::EntityType::RasterImage(img)).unwrap();
// Unreferenced PDF definition → listed, never merged.
let pdf_h = doc.allocate_handle();
let mut pdf_def = UnderlayDefinition::pdf("doc.pdf", "1");
pdf_def.handle = pdf_h;
doc.objects.insert(pdf_h, ObjectType::UnderlayDefinition(pdf_def));
let empty = std::collections::HashSet::new();
let no_prev = std::collections::HashMap::new();
// 1. List: everything visible with the right statuses.
let listed = collect_entries_with_prev(&doc, &dir, &empty, &no_prev);
assert!(listed.iter().any(|e| e.name == "MID" && e.status == RefStatus::Loaded));
assert!(listed.iter().any(|e| e.name == "INNER" && e.parent_key.is_some()));
assert!(listed.iter().any(|e| e.name == "img.png" && e.status == RefStatus::Loaded));
assert!(listed.iter().any(|e| e.name == "doc.pdf" && e.status == RefStatus::Unreferenced));
// 2. Unload: definition retained, content marked.
unload_reference(&mut doc, mid_key).expect("unload");
let mut unloaded = std::collections::HashSet::new();
unloaded.insert(crate::io::xref_model::UnloadKey::Direct(mid_key));
let listed = collect_entries_with_prev(&doc, &dir, &unloaded, &no_prev);
assert!(listed.iter().any(|e| e.name == "MID" && e.status == RefStatus::Unloaded));
assert!(doc.block_records.get("MID").is_some(), "definition retained");
// 3. Targeted reload: back to Loaded.
let mut handles = HashSet::default();
handles.insert(doc.block_records.get("MID").unwrap().handle);
let (infos, _) = resolve_xrefs_for_keys(&mut doc, &dir, &handles);
assert!(infos.iter().any(|i| i.name == "MID"));
let listed = collect_entries_with_prev(&doc, &dir, &empty, &no_prev);
assert!(listed.iter().any(|e| e.name == "MID" && e.status == RefStatus::Loaded));
// 4. Detach: instances, definition, and nested rows all gone.
detach_reference(&mut doc, mid_key).expect("detach");
let listed = collect_entries_with_prev(&doc, &dir, &empty, &no_prev);
assert!(!listed.iter().any(|e| e.name == "MID" || e.name == "INNER"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn nested_child_key_never_aliases_host() {
let host_key = 42u64;
let nested = child_key(100, "DETAIL", "refs/detail.dwg");
assert_ne!(nested, host_key);
}
#[test]
fn set_ref_path_stores_verbatim() {
let (mut doc, key) = xref_doc_with("PLAN", "old/path.dwg");
set_ref_path(&mut doc, key, "new\\raw path.dwg").expect("set path");
let br = doc.block_records.get("PLAN").unwrap();
assert_eq!(br.xref_path, "new\\raw path.dwg");
}
#[test]
fn apply_pathtype_across_drives_errors() {
let (mut doc, key) = xref_doc_with("PLAN", "D:/Lib/plan.dwg");
let host = std::path::Path::new("C:/Drawings/host.dwg");
let err = apply_pathtype(&mut doc, key, Pathtype::Relative, host).unwrap_err();
assert_eq!(err, "XREF: cannot make path relative across drives.");
}
#[test]
#[cfg(windows)]
fn apply_pathtype_full_resolves_existing_relative_path() {
let (mut doc, key) = xref_doc_with("PLAN", "refs/plan.dwg");
let host = std::path::Path::new("C:/Drawings/host.dwg");
apply_pathtype(&mut doc, key, Pathtype::Full, host).expect("full path");
assert_eq!(
doc.block_records.get("PLAN").unwrap().xref_path,
"C:/Drawings/refs/plan.dwg"
);
}
#[test]
fn overlay_on_image_errors() {
use codec::objects::{ImageDefinition, ObjectType};
let mut doc = codec::CadDocument::new();
let h = doc.allocate_handle();
let mut def = ImageDefinition::with_dimensions("img.png", 8, 8);
def.handle = h;
doc.objects.insert(h, ObjectType::ImageDefinition(def));
let err = set_ref_type(&mut doc, h.value(), RefType::Overlay).unwrap_err();
assert_eq!(err, "XREF: overlays apply to drawing references only.");
}
#[test]
fn ref_type_attach_reachable_roundtrip() {
// F10: `set_ref_type(Attach)` (palette Type control, Attach
// direction) flips an overlay back to a full attach.
let (mut doc, key) = xref_doc_with("PLAN", "plan.dwg");
set_ref_type(&mut doc, key, RefType::Overlay).expect("to overlay");
assert!(doc.block_records.get("PLAN").unwrap().flags.is_xref_overlay);
let name = set_ref_type(&mut doc, key, RefType::Attach).expect("to attach");
assert_eq!(name, "PLAN");
let br = doc.block_records.get("PLAN").unwrap();
assert!(br.flags.is_xref && !br.flags.is_xref_overlay);
}
#[test]
fn unload_drops_merged_entities_keeps_definition() {
let (mut doc, key) = xref_doc_with("PLAN", "missing.dwg");
let br_h = doc.block_records.get("PLAN").unwrap().handle;
let mut line = codec::entities::Line::new();
line.common.handle = codec::types::Handle::NULL;
line.common.owner_handle = br_h;
doc.add_entity(codec::EntityType::Line(line)).unwrap();
assert_eq!(doc.block_records.get("PLAN").unwrap().entity_handles.len(), 1);
unload_reference(&mut doc, key).expect("unload");
assert!(doc.block_records.get("PLAN").is_some());
let owned: Vec<_> = doc
.entities()
.filter(|e| e.common().owner_handle == br_h)
.collect();
assert!(owned.is_empty());
}
#[test]
fn detach_erases_inserts_and_definition() {
let (mut doc, key) = xref_doc_with("PLAN", "missing.dwg");
let ins = codec::entities::Insert::new("PLAN", codec::types::Vector3::ZERO);
doc.add_entity(codec::EntityType::Insert(ins)).unwrap();
detach_reference(&mut doc, key).expect("detach");
assert!(doc.block_records.get("PLAN").is_none());
assert!(!doc.entities().any(|e| matches!(
e,
codec::EntityType::Insert(i) if i.block_name.eq_ignore_ascii_case("PLAN")
)));
}
#[test]
fn visretain_snapshot_restore_roundtrip() {
// VISRETAIN=1 reload path: host-side overrides on `name|*` layers
// must survive purge + re-merge. Exercises the mechanism directly:
// snapshot → mutate to defaults → restore → overrides back.
let (mut doc, _) = xref_doc_with("PLAN", "plan.dwg");
let mut layer = codec::tables::Layer::new("PLAN|WALLS");
layer.flags.off = true;
layer.flags.frozen = true;
layer.color = codec::types::Color::from_index(1);
doc.layers.add_or_replace(layer);
let kept = snapshot_dependent_layers(&doc, "PLAN");
assert!(kept.contains_key("PLAN|WALLS"));
// Simulate the purge + file-default re-merge.
doc.layers.remove("PLAN|WALLS");
doc.layers.add_or_replace(codec::tables::Layer::new("PLAN|WALLS"));
assert!(!doc.layers.get("PLAN|WALLS").unwrap().flags.off);
restore_dependent_layers(&mut doc, "PLAN", kept);
let back = doc.layers.get("PLAN|WALLS").unwrap();
assert!(back.flags.off, "off override restored");
assert!(back.flags.frozen, "frozen override restored");
assert_eq!(back.color, codec::types::Color::from_index(1));
}
#[test]
fn visretain_restore_ignores_deleted_layers() {
// Layers gone from the new file version stay gone — restore only
// touches entries present post-merge, never resurrects.
let (mut doc, _) = xref_doc_with("PLAN", "plan.dwg");
let mut layer = codec::tables::Layer::new("PLAN|GONE");
layer.flags.off = true;
doc.layers.add_or_replace(layer);
let kept = snapshot_dependent_layers(&doc, "PLAN");
doc.layers.remove("PLAN|GONE");
restore_dependent_layers(&mut doc, "PLAN", kept);
assert!(doc.layers.get("PLAN|GONE").is_none());
}
#[test]
fn replace_prefix_rewrites_all() {
let (mut doc, _) = xref_doc_with("PLAN", "refs/old/plan.dwg");
let n = replace_path_prefix(&mut doc, "refs/old", "refs/new");
assert_eq!(n, 1);
assert_eq!(
doc.block_records.get("PLAN").unwrap().xref_path,
"refs/new/plan.dwg"
);
}
#[test]
fn replace_prefix_dotdot_saved_path() {
// `..`-laden saved path normalizes to the old prefix; the splice must
// follow normalized components, not raw bytes.
let (mut doc, _) = xref_doc_with("PLAN", "refs/a/../old/plan.dwg");
let n = replace_path_prefix(&mut doc, "refs/old", "refs/new");
assert_eq!(n, 1);
assert_eq!(
doc.block_records.get("PLAN").unwrap().xref_path,
"refs/new/plan.dwg"
);
}
#[test]
#[cfg(windows)]
fn replace_prefix_case_folded_match() {
let (mut doc, _) = xref_doc_with("PLAN", "REFS/OLD/plan.dwg");
let n = replace_path_prefix(&mut doc, "refs/old", "refs/new");
assert_eq!(n, 1);
assert_eq!(
doc.block_records.get("PLAN").unwrap().xref_path,
"refs/new/plan.dwg"
);
}
#[test]
fn replace_prefix_multibyte_no_panic() {
// Old code cut the RAW string by `old.len()` bytes after matching on
// normalized forms — a normalized match with a shorter raw prefix
// panics on a mid-char cut. Component-wise rejoin never slices raw.
// Here old `a/` normalizes to `a`, which prefixes normalized
// `aé/x.dwg`; raw cut at 2 lands inside `é` → old code panicked.
let (mut doc, _) = xref_doc_with("PLAN", "aé/x.dwg");
let n = replace_path_prefix(&mut doc, "a/", "q/");
assert_eq!(n, 0);
assert_eq!(
doc.block_records.get("PLAN").unwrap().xref_path,
"aé/x.dwg"
);
let (mut doc2, _) = xref_doc_with("PLAN", "répértoire/old/plan.dwg");
let n2 = replace_path_prefix(&mut doc2, "répértoire/old", "répértoire/new");
assert_eq!(n2, 1);
assert_eq!(
doc2.block_records.get("PLAN").unwrap().xref_path,
"répértoire/new/plan.dwg"
);
}
#[test]
fn stale_only_after_cached_baseline() {
use std::collections::{HashMap, HashSet};
use std::time::{Duration, UNIX_EPOCH};
// No fs: drive decide_status directly via fabricated times is covered
// in xref_model; here assert the collect wrapper threads `prev`
// through without touching the filesystem (missing file → NotFound,
// never Stale even with a prev entry).
let (doc, key) = xref_doc_with("PLAN", "definitely-missing-xyz.dwg");
let mut prev: HashMap<u64, SystemTimeAlias> = HashMap::new();
prev.insert(key, UNIX_EPOCH + Duration::from_secs(1));
let entries = super::collect_entries_with_prev(
&doc,
std::path::Path::new("."),
&HashSet::new(),
&prev,
);
assert_eq!(entries[0].status, RefStatus::NotFound);
}
#[allow(dead_code)]
type SystemTimeAlias = std::time::SystemTime;
// ── BIND (Task 9) ────────────────────────────────────────────────────
mod bind_tests {
use super::super::bind_reference;
use super::xref_doc_with;
fn bind_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"ocs_xref_bind_{tag}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write_xref_file(
dir: &std::path::Path,
name: &str,
build: impl FnOnce(&mut codec::CadDocument),
) -> String {
let mut doc = codec::CadDocument::new();
build(&mut doc);
let bytes = crate::io::save_to_bytes(&doc, "dwg", doc.version).expect("save xref");
let path = dir.join(name);
std::fs::write(&path, &bytes).expect("write xref");
path.to_string_lossy().into_owned()
}
fn line_on(layer: &str) -> codec::EntityType {
let mut line = codec::entities::Line::new();
line.common.layer = layer.to_string();
codec::EntityType::Line(line)
}
fn add_layer(doc: &mut codec::CadDocument, name: &str) {
doc.layers
.add(codec::tables::Layer::new(name))
.expect("add layer");
}
#[test]
fn bind_rename_basic() {
let dir = bind_dir("basic");
let xref_path = write_xref_file(&dir, "plan.dwg", |doc| {
add_layer(doc, "WALLS");
doc.add_entity(line_on("WALLS")).unwrap();
});
let (mut host, key) = xref_doc_with("PLAN", &xref_path);
let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind");
assert_eq!(outcome.name, "PLAN");
assert_eq!(outcome.unremapped, 0);
let br = host.block_records.get("PLAN").unwrap();
assert!(!br.flags.is_xref && !br.flags.is_xref_overlay);
assert!(br.xref_path.is_empty());
assert!(host.layers.get("PLAN$0$WALLS").is_some());
let owned: Vec<_> = host
.entities()
.filter(|e| e.common().owner_handle == br.handle)
.collect();
assert_eq!(owned.len(), 1);
assert_eq!(owned[0].common().layer, "PLAN$0$WALLS");
assert!(!host.layers.names().any(|n| n.contains('|')));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn bind_collision_increments() {
let dir = bind_dir("collision");
let xref_path = write_xref_file(&dir, "plan.dwg", |doc| {
add_layer(doc, "WALLS");
doc.add_entity(line_on("WALLS")).unwrap();
});
let (mut host, key) = xref_doc_with("PLAN", &xref_path);
add_layer(&mut host, "PLAN$0$WALLS");
let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind");
assert_eq!(outcome.name, "PLAN");
assert!(host.layers.get("PLAN$1$WALLS").is_some());
let br = host.block_records.get("PLAN").unwrap();
let owned: Vec<_> = host
.entities()
.filter(|e| e.common().owner_handle == br.handle)
.collect();
assert_eq!(owned.len(), 1);
assert_eq!(owned[0].common().layer, "PLAN$1$WALLS");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn bind_transitive_chain() {
let dir = bind_dir("chain");
let detail_path = write_xref_file(&dir, "detail.dwg", |doc| {
add_layer(doc, "WALLS");
doc.add_entity(line_on("WALLS")).unwrap();
});
let plan_path = write_xref_file(&dir, "plan.dwg", |doc| {
add_layer(doc, "PLANLAYER");
doc.add_entity(line_on("PLANLAYER")).unwrap();
let mut br = codec::tables::BlockRecord::new("DETAIL");
br.flags.is_xref = true;
br.xref_path = detail_path.clone();
doc.block_records.add(br).expect("add nested xref");
});
let (mut host, key) = xref_doc_with("PLAN", &plan_path);
let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind");
assert_eq!(outcome.name, "PLAN");
assert!(host.layers.get("PLAN$0$DETAIL$0$WALLS").is_some());
assert!(host.layers.get("PLAN$0$PLANLAYER").is_some());
let child = host.block_records.get("PLAN$0$DETAIL").expect("bound child block");
assert!(!child.flags.is_xref && !child.flags.is_xref_overlay);
assert!(!host
.block_records
.iter()
.any(|b| b.flags.is_xref || b.flags.is_xref_overlay));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn bind_remaps_hatch_dim_block_refs() {
let dir = bind_dir("refs");
let xref_path = write_xref_file(&dir, "plan.dwg", |doc| {
add_layer(doc, "WALLS");
// Nested block with content + a model-space INSERT of it.
let mut door = codec::tables::BlockRecord::new("DOOR");
door.handle = doc.allocate_handle();
let door_h = door.handle;
doc.block_records.add(door).expect("add door");
let mut inner = codec::entities::Line::new();
inner.common.layer = "WALLS".to_string();
inner.common.owner_handle = door_h;
doc.add_entity(codec::EntityType::Line(inner)).unwrap();
let ins = codec::entities::Insert::new(
"DOOR",
codec::types::Vector3::ZERO,
);
doc.add_entity(codec::EntityType::Insert(ins)).unwrap();
// Hatch (pattern names have no table — preserved verbatim).
let mut hatch = codec::entities::Hatch::new();
hatch.pattern.name = "ANSI31".to_string();
doc.add_entity(codec::EntityType::Hatch(hatch)).unwrap();
// Dim-style reference via a tolerance entity.
doc.dim_styles
.add(codec::tables::DimStyle::new("ARCH"))
.expect("add dimstyle");
let mut tol = codec::entities::Tolerance::new();
tol.dimension_style_name = "ARCH".to_string();
doc.add_entity(codec::EntityType::Tolerance(tol)).unwrap();
});
let (mut host, key) = xref_doc_with("PLAN", &xref_path);
bind_reference(&mut host, key, &dir, &dir).expect("bind");
let door = host.block_records.get("PLAN$0$DOOR").expect("bound block");
assert!(!door.flags.is_xref);
let inserts: Vec<_> = host
.entities()
.filter_map(|e| match e {
codec::EntityType::Insert(i) => Some(i.block_name.clone()),
_ => None,
})
.collect();
assert!(inserts.iter().any(|n| n == "PLAN$0$DOOR"), "{inserts:?}");
let door_lines: Vec<_> = host
.entities()
.filter(|e| e.common().owner_handle == door.handle)
.collect();
assert_eq!(door_lines.len(), 1);
assert_eq!(door_lines[0].common().layer, "PLAN$0$WALLS");
let br = host.block_records.get("PLAN").unwrap();
let hatches: Vec<_> = host
.entities()
.filter(|e| {
e.common().owner_handle == br.handle
&& matches!(e, codec::EntityType::Hatch(_))
})
.collect();
assert_eq!(hatches.len(), 1);
assert!(host.dim_styles.get("PLAN$0$ARCH").is_some());
let tols: Vec<_> = host
.entities()
.filter_map(|e| match e {
codec::EntityType::Tolerance(t) => Some(t.dimension_style_name.clone()),
_ => None,
})
.collect();
assert_eq!(tols, vec!["PLAN$0$ARCH".to_string()]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn bind_remaps_text_styles_and_counts_unmapped() {
let dir = bind_dir("styles");
let xref_path = write_xref_file(&dir, "plan.dwg", |doc| {
doc.text_styles
.add(codec::tables::TextStyle::new("NOTE"))
.expect("add text style");
let mut text = codec::entities::Text::new();
text.style = "NOTE".to_string();
doc.add_entity(codec::EntityType::Text(text)).unwrap();
// Two foreign handles that cannot be remapped (plotstyle_flags
// set as a real writer would — otherwise the round-trip
// drops the handle).
let mut line = codec::entities::Line::new();
line.common.plotstyle_flags = 0b11;
line.common.plotstyle_handle = Some(doc.allocate_handle());
doc.add_entity(codec::EntityType::Line(line)).unwrap();
let mut mline = codec::entities::MLine::new();
mline.style_name = "STD".to_string();
mline.style_handle = Some(doc.allocate_handle());
doc.add_entity(codec::EntityType::MLine(mline)).unwrap();
});
let (mut host, key) = xref_doc_with("PLAN", &xref_path);
let outcome = bind_reference(&mut host, key, &dir, &dir).expect("bind");
assert_eq!(outcome.unremapped, 2);
assert!(host.text_styles.get("PLAN$0$NOTE").is_some());
let styles: Vec<_> = host
.entities()
.filter_map(|e| match e {
codec::EntityType::Text(t) => Some(t.style.clone()),
_ => None,
})
.collect();
assert_eq!(styles, vec!["PLAN$0$NOTE".to_string()]);
let br = host.block_records.get("PLAN").unwrap();
let lines: Vec<_> = host
.entities()
.filter(|e| {
e.common().owner_handle == br.handle
&& matches!(e, codec::EntityType::Line(_))
})
.collect();
assert_eq!(lines.len(), 1);
assert!(lines[0].common().plotstyle_handle.is_none());
let mlines: Vec<_> = host
.entities()
.filter_map(|e| match e {
codec::EntityType::MLine(m) => Some(m.style_handle),
_ => None,
})
.collect();
assert_eq!(mlines, vec![None]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn bind_image_collect_copies_file() {
use codec::objects::{ImageDefinition, ObjectType};
let src_dir = bind_dir("imgsrc");
let host_dir = bind_dir("imghost");
std::fs::write(src_dir.join("pixel.png"), b"fakepng").unwrap();
let xref_path = write_xref_file(&src_dir, "plan.dwg", |doc| {
let h = doc.allocate_handle();
let mut def = ImageDefinition::with_dimensions("pixel.png", 8, 8);
def.handle = h;
doc.objects.insert(h, ObjectType::ImageDefinition(def));
let mut img = codec::entities::RasterImage::new(
"pixel.png",
codec::types::Vector3::ZERO,
8.0,
8.0,
);
img.definition_handle = Some(h);
doc.add_entity(codec::EntityType::RasterImage(img)).unwrap();
});
let (mut host, key) = xref_doc_with("PLAN", &xref_path);
bind_reference(&mut host, key, &src_dir, &host_dir).expect("bind");
let copied = host_dir.join("pixel.png");
assert!(copied.exists());
assert_eq!(std::fs::read(&copied).unwrap(), b"fakepng");
let defs: Vec<_> = host
.objects
.values()
.filter_map(|o| match o {
ObjectType::ImageDefinition(d) => Some(d.file_name.clone()),
_ => None,
})
.collect();
assert_eq!(defs, vec!["pixel.png".to_string()]);
std::fs::remove_dir_all(&src_dir).ok();
std::fs::remove_dir_all(&host_dir).ok();
}
#[test]
fn bind_image_missing_errors_without_partial_state() {
use codec::objects::{ImageDefinition, ObjectType};
let dir = bind_dir("imgmissing");
let xref_path = write_xref_file(&dir, "plan.dwg", |doc| {
let h = doc.allocate_handle();
let mut def = ImageDefinition::with_dimensions("ghost.png", 8, 8);
def.handle = h;
doc.objects.insert(h, ObjectType::ImageDefinition(def));
let mut img = codec::entities::RasterImage::new(
"ghost.png",
codec::types::Vector3::ZERO,
8.0,
8.0,
);
img.definition_handle = Some(h);
doc.add_entity(codec::EntityType::RasterImage(img)).unwrap();
});
let (mut host, key) = xref_doc_with("PLAN", &xref_path);
let err = bind_reference(&mut host, key, &dir, &dir).unwrap_err();
assert!(err.contains("ghost.png"), "got: {err:?}");
// No partial state: the definition is untouched.
let br = host.block_records.get("PLAN").unwrap();
assert!(br.flags.is_xref);
assert_eq!(br.xref_path, xref_path);
assert!(!host.layers.names().any(|n| n.contains("PLAN$0")));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn bind_pdf_errors() {
use codec::objects::{ObjectType, UnderlayDefinition};
let mut host = codec::CadDocument::new();
let h = host.allocate_handle();
let mut def = UnderlayDefinition::pdf("doc.pdf", "1");
def.handle = h;
host.objects.insert(h, ObjectType::UnderlayDefinition(def));
let err =
bind_reference(&mut host, h.value(), std::path::Path::new("."), std::path::Path::new("."))
.unwrap_err();
assert_eq!(
err,
"XREF: PDF bind (vector import) is not available in this version."
);
}
#[test]
fn bind_unknown_key_errors() {
let (mut host, _) = xref_doc_with("PLAN", "missing.dwg");
let err = bind_reference(
&mut host,
0xDEAD_BEEF,
std::path::Path::new("."),
std::path::Path::new("."),
)
.unwrap_err();
assert_eq!(err, "XREF: no loaded reference with that key.");
}
}
}