// 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::collections::VecDeque; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; #[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, pub read_stats: Option, pub source_sha256: Option, } /// Names of the xrefs that resolved to nothing (`NotFound`) — the list the /// one-line "unresolved references" warning after a web open shows. pub fn unresolved_xref_names(infos: &[XrefInfo]) -> Vec { infos .iter() .filter(|info| info.status == XrefStatus::NotFound) // The file name is what the user must drop on the page; the block // name may be a renamed `A$C$...` alias that matches no file. // Split on both separators: drawings carry Windows paths, which // `Path` does not split off Windows. .map(|info| { info.path .rsplit(['/', '\\']) .next() .map(str::trim) .filter(|n| !n.is_empty()) .map_or_else(|| info.name.clone(), str::to_string) }) .collect() } // ── In-session xref byte registry (web) ──────────────────────────────── // // The browser exposes no filesystem behind the path a drawing's XREF block // carries, so the web build keeps the bytes of every DWG/DXF the user picked // or dropped, keyed by file name — the xref counterpart of // `image_model::register_source`. Resolution matches a block-record's // `xref_path` against the registry by trailing file name (case-insensitive), // the only component two machines can be expected to agree on. Native builds // never consult the registry: `resolve_path` reads the real filesystem, so // their behaviour is byte-identical to before. The registry itself compiles // on every target so its pure matching logic stays unit-testable on the // native host. /// Lower-cased trailing component of a path-style xref reference. Drawings /// store xref paths as the author wrote them (often an absolute foreign /// path), so the file name is the only part a picked or dropped file on /// another machine can be expected to match. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native pub(crate) fn file_name_key(path: &str) -> String { path.rsplit(['/', '\\']) .next() .unwrap_or_default() .to_ascii_lowercase() } /// Do two xref references name the same file? Compares trailing components /// case-insensitively (the web has no path beyond the name at all, and the /// Windows paths most drawings were authored on are case-insensitive). #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native pub(crate) fn same_file_name(a: &str, b: &str) -> bool { let (a, b) = (file_name_key(a), file_name_key(b)); !a.is_empty() && a == b } /// Upper bound on registered drawings. Every open, drop, and XATTACH pick /// registers its bytes; without a cap a day of opening large drawings grows /// the wasm heap until the tab dies. Oldest registrations leave first — /// enough for a parent drawing plus its usual handful of references. const SOURCE_CAP: usize = 6; fn source_cache() -> &'static Mutex<(HashMap>>, VecDeque)> { static SOURCES: OnceLock>>, VecDeque)>> = OnceLock::new(); SOURCES.get_or_init(|| Mutex::new((HashMap::default(), VecDeque::new()))) } fn parsed_cache() -> &'static Mutex> { static PARSED: OnceLock>> = OnceLock::new(); PARSED.get_or_init(|| Mutex::new(HashMap::default())) } /// Keep a picked or dropped drawing's bytes, keyed by the file name the /// drawing's xref references will be matched against. Registering supersedes /// any eager parse cached for the same file, so a re-pick (or a drop that /// finally supplies a missing reference) reloads from the new bytes. /// /// Names are bare on the web, so two DISTINCT same-named drawings cannot be /// told apart: the newest registration wins for resolution, ARCHIVE, and /// XRELOAD alike — consistent with what the drawing itself displays, but a /// fundamental browser limit (native distinguishes them by folder). #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // mirrors image_model::register_source pub fn register_source(name: &str, bytes: Arc>) { { let mut cache = source_cache() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let (map, order) = &mut *cache; if !map.contains_key(name) { order.push_back(name.to_string()); while order.len() > SOURCE_CAP { if let Some(evicted) = order.pop_front() { map.remove(&evicted); } } } map.insert(name.to_string(), bytes); } parsed_cache() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .retain(|parsed, _| !same_file_name(parsed, name)); } /// Cache an off-thread parse (web parse worker) for the next resolve to /// merge. The entry is one-shot: a resolve consumes it, so a later reload /// re-reads the registered bytes instead of replaying a stale document. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) fn register_parsed(name: &str, outcome: codec::ReadOutcome) { parsed_cache() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .insert(name.to_string(), outcome); } /// Registry key whose trailing file name matches `raw` (case-insensitive) — /// the web stand-in for `resolve_path`, which has no filesystem to consult. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] // unit-tested on native pub(crate) fn find_registered_source(raw: &str) -> Option { source_cache() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .0 .keys() .find(|key| same_file_name(key, raw)) .cloned() } /// Bytes registered under exactly `key`. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) fn registered_bytes(key: &str) -> Option>> { source_cache() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .0 .get(key) .cloned() } /// Take the cached parse for `key`, if one is pending. #[cfg(any(target_arch = "wasm32", test))] fn take_parsed(key: &str) -> Option { parsed_cache() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .remove(key) } /// Read a registered xref on the web: the cached off-thread parse when the /// pick/open pre-pass produced one, otherwise a direct parse of the /// registered bytes (the eager parse never caches recoverable outcomes — /// those must keep their stats so phase 2 reports them as Failed, like the /// native reader does). #[cfg(target_arch = "wasm32")] fn read_registered_outcome( key: &str, progress: Option>, ) -> Result { if let Some(mut outcome) = take_parsed(key) { finalize_xref_outcome(&mut outcome); return Ok(outcome); } let Some(bytes) = registered_bytes(key) else { return Err(format!("no registered bytes for xref \"{key}\"")); }; let mut outcome = read_outcome_from_bytes(key, bytes, progress)?; finalize_xref_outcome(&mut outcome); Ok(outcome) } /// Apply the same post-parse fixups the file-open tail applies, so merged /// xref content behaves like natively loaded content. #[cfg(target_arch = "wasm32")] fn finalize_xref_outcome(outcome: &mut codec::ReadOutcome) { let doc = &mut outcome.document; super::normalize_block_origins(doc); if outcome.stats.source_format == Some(codec::SourceFormat::Dxf) { super::fix_dxf_dimension_rotations(doc); super::fix_dxf_layout_plot_settings(doc); } super::fix_viewport_status_flags(doc); super::fix_current_style_names(doc); } /// Parse DWG/DXF bytes into a reader outcome — `load_file_with_progress` /// for the browser, where the "file" is the registry entry. Recoverable /// parse issues stay in the stats instead of failing the read, matching the /// native xref loader's behaviour (phase 2 turns them into Failed). #[cfg(target_arch = "wasm32")] fn read_outcome_from_bytes( name: &str, bytes: Arc>, progress: Option>, ) -> Result { use codec::io::dwg::DwgReader; use codec::DxfReader; // The readers keep their stream and want an owned, 'static buffer — // take the registry `Arc`'s contents by value (one copy; the worker // pre-parse makes this fallback path the exception, not the rule). let bytes = bytes.to_vec(); let ext = name.rsplit('.').next().unwrap_or_default().to_lowercase(); // A `.bak`/`.sv$` copy hides the real format behind its extension — // sniff the leading bytes, exactly as the path-based reader does. let effective = if matches!(ext.as_str(), "bak" | "sv$") { super::sniff_dwg_or_dxf_bytes(&bytes).to_string() } else { ext.clone() }; let outcome = match effective.as_str() { "dwg" => { let mut reader = DwgReader::from_stream(std::io::Cursor::new(bytes)); if let Some(progress) = progress { reader.set_progress_callback(progress); } reader.read_with_stats() } "dxf" => DxfReader::from_reader(std::io::Cursor::new(bytes)) .and_then(|reader| reader.read_with_stats()), _ => return Err(format!("Unsupported file format: .{ext}")), }; let outcome = outcome.map_err(|error| error.to_string())?; if !outcome.stats.has_usable_drawing_data() { return Err("initial read returned no source drawing records".to_string()); } Ok(outcome) } /// 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, 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 `: 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, ) -> (Vec, 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>, ) -> (Vec, usize) { resolve_xrefs_with_filter(doc, base_dir, progress, None) } fn resolve_xrefs_with_filter( doc: &mut CadDocument, base_dir: &Path, progress: Option>, only: Option<&HashSet>, ) -> (Vec, 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> = std::sync::Arc::new( (0..xref_count) .map(|_| std::sync::atomic::AtomicU16::new(0)) .collect(), ); let parsed: Vec<( String, String, Handle, Option, Option>, Option, Option, )> = 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 = 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 }); #[cfg(not(target_arch = "wasm32"))] let xref_outcome = resolved .as_ref() .map(|path| super::load_file_with_progress(path, nested_progress)); // Web: read the registry — the cached off-thread parse when the // pick/open pre-pass made one, otherwise the registered bytes. #[cfg(target_arch = "wasm32")] let xref_outcome = resolved .as_ref() .map(|path| read_registered_outcome(&path.to_string_lossy(), 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 = 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)); } } // Point cloud scans are large and read lazily: register where they are. for object in doc.objects.values() { let codec::objects::ObjectType::ClassObject(object) = object else { continue; }; let (codec::objects::ClassObjectData::PointCloudDefinitionEx(def) | codec::objects::ClassObjectData::PointCloudDefinition(def)) = &object.data else { continue; }; let stored = def.source_filename.trim(); if let Some(found) = (!stored.is_empty()) .then(|| resolve_path(stored, base_dir)) .flatten() { crate::scene::model::point_cloud::register_source(stored, found); } } } /// 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> { type Cache = std::sync::Mutex)>>; static CACHE: std::sync::OnceLock = 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 = 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 }) } /// Web: there is no filesystem behind a stored path, so a reference resolves /// to the name-keyed bytes registry instead (trailing-name match); the /// returned path is the registry key. #[cfg(target_arch = "wasm32")] pub(crate) fn resolve_path(raw: &str, _base_dir: &Path) -> Option { find_registered_source(raw).map(PathBuf::from) } /// Try to build an absolute path from a raw xref path string. /// Handles absolute paths, relative paths, and Windows-style separators. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn resolve_path(raw: &str, base_dir: &Path) -> Option { 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 on BIND (`*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, linetypes: HashSet, text_styles: HashSet, dim_styles: HashSet, blocks: HashSet, } impl BindTaken { fn capture(doc: &CadDocument) -> Self { let upper_names = |names: Vec| 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 { 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, linetypes: HashMap, text_styles: HashMap, text_style_handles: HashMap, dim_styles: HashMap, blocks: HashMap, br_handles: HashMap, image_defs: HashMap, underlay_defs: HashMap, } /// 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(); // Layer 0 and Defpoints are never dependent symbols: the reference's // objects on them use the host's layer of the same name (made from // the reference's when the host has none), on load and on bind. if old == "0" || old.eq_ignore_ascii_case("Defpoints") { if !doc.layers.contains(&old) { let mut cloned = layer.clone(); cloned.plotstyle_handle = host_plotstyle; cloned.material = host_material; cloned.flags.xref_dependent = false; cloned.xref_block_record_handle = Handle::NULL; cloned.set_handle(doc.allocate_handle()); let _ = doc.layers.add(cloned); } let host = doc.layers.get(&old).map_or(old.clone(), |l| l.name.clone()); maps.layers.insert(old.to_uppercase(), host); continue; } 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). // The load path also takes the anonymous pictures (`*D` dimensions, `*U` // block representations): a dimension draws from its block by name, and // without the copy it falls back to a host block of the same name or a // regeneration that misses the file's own overrides. BIND keeps skipping // them, as a `$N$` prefix would make an invalid anonymous name. let take_anonymous = matches!(naming, SymbolNaming::MergePipe { .. }); for br in xref_doc.block_records.iter() { let anonymous = br.name.starts_with('*'); let layout_block = br.is_layout() || br.name.eq_ignore_ascii_case("*Model_Space") || br.name.to_ascii_lowercase().starts_with("*paper_space"); if (anonymous && (!take_anonymous || layout_block)) || 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) -> 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, 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 + the dimension's picture block ── EntityType::Dimension(d) => { rename(&maps.dim_styles, &mut d.base_mut().style_name); rename(&maps.blocks, &mut d.base_mut().block_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, entity_handle_map: &HashMap, ) { 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 = Vec::new(); let mut seen: HashSet = 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) { let source_ms_handle = source.header.model_space_block_handle; let mut entity_handle_map: HashMap = 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, ) -> Vec { 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, prev: &std::collections::HashMap, ) -> Vec { use crate::io::xref_model::{ child_key, decide_status, normalize_lexical, RefKind, RefStatus, RefType, ReferenceEntry, UnloadKey, }; use codec::objects::ObjectType; /// 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: a /// source present in that registry reports Loaded here, and only truly /// unregistered ones stay `NotFound`. fn stat_into(entry: &mut ReferenceEntry, raw: &str, base_dir: &Path) { if raw.is_empty() { return; } // Web (and the native tests exercising it): `resolve_path` probes the // filesystem, which the browser does not have — consult the source // registry instead. A registered drawing IS loaded; its byte length // comes free from the in-memory bytes (no read), and there is no // mtime to report. #[cfg(any(target_arch = "wasm32", test))] if let Some(key) = find_registered_source(raw) { entry.size_bytes = registered_bytes(&key).map(|b| b.len() as u64); entry.found_at = Some(key); entry.status = RefStatus::Loaded; 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 = 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 = 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 = 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); } // ── Point clouds ── // Listed under their definitions' names; a definition no cloud uses is // Unreferenced, as for images and underlays. let mut referenced_clouds: HashSet = HashSet::default(); for e in doc.entities() { if let EntityType::Extended(extended) = e { if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data { referenced_clouds.insert(data.definition_handle); } } } let cloud_names: HashMap = doc .objects .values() .filter_map(|object| match object { ObjectType::Dictionary(dictionary) => Some(dictionary), _ => None, }) .flat_map(|dictionary| { dictionary .entries .iter() .map(|(name, handle)| (*handle, name.clone())) }) .collect(); for (handle, obj) in doc.objects.iter() { let ObjectType::ClassObject(object) = obj else { continue; }; let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &object.data else { continue; }; let key = handle.value(); let name = cloud_names.get(handle).cloned().unwrap_or_else(|| { Path::new(&def.source_filename.replace('\\', "/")) .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default() }); let mut entry = ReferenceEntry::new(key, name, RefKind::PointCloud); entry.saved_path = def.source_filename.clone(); if !def.is_loaded { entry.status = RefStatus::Unloaded; resolve_only(&mut entry, &def.source_filename, base_dir); } else { stat_into(&mut entry, &def.source_filename, base_dir); entry.status = decide_status(entry.status, entry.modified, prev.get(&key).copied(), false); if !referenced_clouds.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 = 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 = 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 = 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 }, PointCloud { handle: Handle, name: String }, } fn find_target(doc: &CadDocument, key: u64) -> Option { 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::ClassObject(object) if handle.value() == key => { if let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &object.data { return Some(RefTarget::PointCloud { handle: *handle, name: def.source_filename.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 { 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 = doc .entities() .filter(|e| e.common().owner_handle == handle) .map(|e| e.common().handle) .collect(); let owned_set: HashSet = 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 = 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) } // So does a point cloud (its definition's loaded flag). RefTarget::PointCloud { handle, name } => { set_point_cloud_loaded(doc, handle, false); Ok(name) } } } /// A point cloud definition's loaded flag (Unload / Reload). pub fn set_point_cloud_loaded(doc: &mut CadDocument, handle: Handle, loaded: bool) { if let Some(codec::objects::ObjectType::ClassObject(object)) = doc.objects.get_mut(&handle) { if let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &mut object.data { def.is_loaded = loaded; } } } /// 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 { 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 = 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 = 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 = 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 = 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 = 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) } // The clouds, their reactors, the definition and its dictionary // entry. RefTarget::PointCloud { handle, name } => { use codec::entities::ExtendedEntityData; let clouds: Vec<(Handle, Handle)> = doc .entities() .filter_map(|e| match e { EntityType::Extended(extended) => match &extended.data { ExtendedEntityData::PointCloudEx(data) if data.definition_handle == handle => { Some((e.common().handle, data.reactor_handle)) } _ => None, }, _ => None, }) .collect(); for (cloud, reactor) in clouds { doc.remove_entity(cloud); doc.objects.remove(&reactor); } for object in doc.objects.values_mut() { if let codec::objects::ObjectType::Dictionary(dictionary) = object { dictionary.entries.retain(|(_, h)| *h != handle); } } 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 { 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()), RefTarget::PointCloud { .. } => { Err(crate::t!("XREF: bind applies to drawing references only.").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 { let saved = doc .block_records .iter() .find(|b| b.handle == br_handle) .map(|b| b.xref_path.clone()) .unwrap_or_default(); // Native-only path: `resolve_path` reads the real filesystem, so DWG // bind stays desktop-only (the web has no fs to load a bind source from). 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 { 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, ) -> Result { 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, ) -> 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 = HashMap::default(); let mut ops: Vec<(Handle, PathBuf, String)> = Vec::new(); for (handle, saved) in &defs { // Native-only path: `resolve_path` stats the real filesystem (and // the copy below reads it), so image COLLECT stays desktop-only. 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 = 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, } } /// Marks a point cloud definition (by handle) among the references made /// relative on the drawing's first save. pub const POINT_CLOUD_KEY: &str = "pointcloud:"; /// 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, host: &Path) { for name in names { if let Some(handle) = name.strip_prefix(POINT_CLOUD_KEY) { if let Ok(handle) = u64::from_str_radix(handle, 16) { relative_point_cloud_path(doc, Handle::new(handle), host); } continue; } 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 = 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 { 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 = doc .block_records .iter() .filter(|b| is_dep(&b.name)) .map(|b| b.handle) .collect(); let owned: Vec = 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 = 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 = 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 = 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 = 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 = 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 = 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 { 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, ) { if kept.is_empty() { return; } let prefix = format!("{}|", name.to_uppercase()); let targets: Vec = 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 { 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()), } } RefTarget::PointCloud { handle, name } => { use codec::objects::{ClassObjectData, ObjectType}; match doc.objects.get_mut(&handle) { Some(ObjectType::ClassObject(object)) => match &mut object.data { ClassObjectData::PointCloudDefinitionEx(def) => { def.source_filename = new_raw.to_string(); Ok(name) } _ => Err(crate::t!("XREF: no loaded reference with that key.").to_string()), }, _ => 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 { 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(), } } Some(RefTarget::PointCloud { name, .. }) => name, 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 { 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 { .. } | RefTarget::PointCloud { .. } => { 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 = 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."); } } } /// A point cloud definition's full path made relative to the drawing /// (".\rcs\scan.rcs", as the reference writes it); on another drive it /// stays full. fn relative_point_cloud_path(doc: &mut CadDocument, handle: Handle, host: &Path) { use crate::io::xref_model::{to_pathtype_result, Pathtype}; let Some(codec::objects::ObjectType::ClassObject(object)) = doc.objects.get_mut(&handle) else { return; }; let codec::objects::ClassObjectData::PointCloudDefinitionEx(def) = &mut object.data else { return; }; if let Ok(relative) = to_pathtype_result(&def.source_filename, host, Pathtype::Relative) { let relative = relative.replace('/', "\\"); if let Some(found) = std::path::Path::new(&def.source_filename) .is_file() .then(|| def.source_filename.clone()) { crate::scene::model::point_cloud::register_source(&relative, found.into()); } def.source_filename = relative; } } #[cfg(test)] mod web_registry_tests { use super::*; /// The registries are process-global and the default harness runs tests /// in parallel; the eviction test's inserts can evict a sibling test's /// entry mid-assert. Registry tests hold this lock for their duration. fn registry_guard() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } fn outcome() -> codec::ReadOutcome { codec::ReadOutcome::new(CadDocument::new(), codec::ReadStats::default()) } #[test] fn file_name_key_takes_the_trailing_component_case_folded() { assert_eq!(file_name_key("Site Plan.dwg"), "site plan.dwg"); assert_eq!( file_name_key(r"D:\Projects\Arch\XREFS\Elevation.DWG"), "elevation.dwg" ); assert_eq!(file_name_key("../consultant/model.dxf"), "model.dxf"); assert_eq!(file_name_key(""), ""); } #[test] fn same_file_name_ignores_path_and_case_but_not_empty() { assert!(same_file_name( r"C:\foreign\abs\PATH\Elevation.DWG", "elevation.dwg" )); assert!(same_file_name("a/x.dwg", r"b\X.Dwg")); assert!(!same_file_name("elevation.dwg", "elevation-2.dwg")); // An empty trailing component names nothing — never a match, or a // drawing with a blank xref path would resolve to any empty key. assert!(!same_file_name("", "")); assert!(!same_file_name("", "x.dwg")); } #[test] fn registry_finds_a_registered_source_by_trailing_name() { let _registry = registry_guard(); let registered = "Registry-Find-Site.dwg"; register_source(registered, Arc::new(vec![1, 2, 3])); // The drawing's stored path is the author's absolute foreign path; // only the trailing name can match, case-insensitively. assert_eq!( find_registered_source(r"E:\2009\Consultant\REGISTRY-FIND-SITE.DWG"), Some(registered.to_string()) ); assert_eq!(find_registered_source("registry-find-site.dxf"), None); assert_eq!( registered_bytes(registered).map(|bytes| bytes.as_ref().clone()), Some(vec![1, 2, 3]) ); } /// Beyond SOURCE_CAP registrations the oldest leave first, and a /// re-registration of a live name neither evicts nor duplicates it. #[test] fn source_registry_evicts_oldest_beyond_the_cap() { let _registry = registry_guard(); for i in 0..(SOURCE_CAP + 2) { register_source(&format!("f{i}.dwg"), Arc::new(vec![i as u8])); } let (map_len, order_len, has_newest, has_oldest) = { // Scoped: the guard must drop before register_source locks again. let cache = source_cache() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); ( cache.0.len(), cache.1.len(), cache.0.contains_key(&format!("f{}.dwg", SOURCE_CAP + 1)), cache.0.contains_key("f0.dwg"), ) }; assert_eq!(map_len, SOURCE_CAP); assert_eq!(order_len, SOURCE_CAP); assert!(has_newest); assert!(!has_oldest); // Re-registering a live name refreshes its bytes without reordering. register_source(&format!("f{}.dwg", SOURCE_CAP + 1), Arc::new(vec![255])); let (order_len, refreshed) = { let cache = source_cache() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); ( cache.1.len(), cache .0 .get(&format!("f{}.dwg", SOURCE_CAP + 1)) .map(|b| b.to_vec()), ) }; assert_eq!(order_len, SOURCE_CAP, "no duplicate order entry"); assert_eq!(refreshed, Some(vec![255])); } #[test] fn register_source_supersedes_a_cached_parse_of_the_same_file() { let _registry = registry_guard(); let name = "Registry-Supersede-Plan.dwg"; register_parsed(name, outcome()); // Re-registering the same file under a differently-cased path name // must drop the stale parse so a reload re-reads the new bytes. register_source("registry-supersede-plan.DWG", Arc::new(vec![9])); assert!(take_parsed(name).is_none()); // And the fresh bytes are what the registry hands back. assert_eq!( find_registered_source(name).as_deref(), Some("registry-supersede-plan.DWG") ); } #[test] fn unresolved_xref_names_lists_not_found_entries_only() { let infos = vec![ XrefInfo { name: "Loaded.dwg".into(), path: "Loaded.dwg".into(), status: XrefStatus::Loaded, diagnostics: Vec::new(), read_stats: None, source_sha256: None, }, XrefInfo { name: "Missing A.dwg".into(), path: r"C:\gone\Missing A.dwg".into(), status: XrefStatus::NotFound, diagnostics: Vec::new(), read_stats: None, source_sha256: None, }, XrefInfo { name: "Broken.dwg".into(), path: "Broken.dwg".into(), status: XrefStatus::Failed, diagnostics: Vec::new(), read_stats: None, source_sha256: None, }, XrefInfo { name: "Missing B.dxf".into(), path: "Missing B.dxf".into(), status: XrefStatus::NotFound, diagnostics: Vec::new(), read_stats: None, source_sha256: None, }, ]; assert_eq!( unresolved_xref_names(&infos), vec!["Missing A.dwg".to_string(), "Missing B.dxf".to_string()] ); assert!(unresolved_xref_names(&[]).is_empty()); } }