feat: XREF management — auto-resolve, XATTACH, XREF, XRELOAD

- src/io/xref.rs: resolve_xrefs() scans block_records for is_xref flags,
  resolves paths relative to the opened file's directory, loads the
  referenced DWG/DXF and merges model-space entities into the xref block
- src/modules/insert/xattach.rs: XATTACH command with file-picker flow
  (XAttachPick/XAttachPickResult messages), prepare_xref_block() creates
  BlockRecord + Block entities and immediately resolves content
- XREF command: lists all external references in the drawing
- XRELOAD command: re-resolves all xref paths and rebuilds scene caches
- Auto-resolve on FileOpened: reports Loaded/NotFound per xref block
- CadCommand trait: added optional xattach_path() method
- ROADMAP.md: 1.9 XREF management marked 

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-08 00:02:38 +03:00
commit 64837e9aed
10 changed files with 487 additions and 3 deletions

View file

@ -18,7 +18,7 @@ Durum simgeleri: ✅ Tamamlandı · 🔧 Kısmen yapıldı · ⬜ Yapılmadı
| 1.6 | Undo / Redo (snapshot stack) | ✅ |
| 1.7 | PDF dışa aktarma (CTB/STB plot style) | ✅ |
| 1.8 | Fiziksel yazıcıya yazdırma | ⬜ |
| 1.9 | XREF (dış referans) yönetimi | |
| 1.9 | XREF (dış referans) yönetimi | ✅ Auto-resolve on open, XATTACH/XREF/XRELOAD commands |
| 1.10 | WBLOCK — bloğu dış dosyaya yazma | ⬜ |
| 1.11 | Serde entegrasyonu (JSON/alternatif I/O) | ⬜ |
| 1.12 | Bozuk DWG kurtarma (failsafe parse) | ⬜ |

View file

@ -65,6 +65,27 @@ impl H7CAD {
self.refresh_properties();
}
CmdResult::CommitAndExit(entity) => {
// For XATTACH: ensure the xref block definition exists before
// committing the INSERT entity that references it.
// Extract path early to avoid borrow conflicts.
let xattach_path: Option<String> = {
let tab = &self.tabs[i];
if let Some(cmd) = tab.active_cmd.as_ref() {
if cmd.name() == "XATTACH" {
cmd.xattach_path()
} else {
None
}
} else {
None
}
};
if let Some(path) = xattach_path {
crate::modules::insert::xattach::prepare_xref_block(
&mut self.tabs[i].scene,
&path,
);
}
let label = self.history_label_from_active_cmd(i, "ENTITY");
self.push_undo_snapshot(i, label);
self.commit_entity(entity);

View file

@ -417,6 +417,74 @@ impl H7CAD {
}
}
"XATTACH" | "XA" => {
// Launch the file picker; XAttachPickResult will start the command.
return Task::done(Message::XAttachPick);
}
"XREF" | "XR" => {
// List all xref blocks in the current drawing.
let xrefs: Vec<String> = self.tabs[i]
.scene
.document
.block_records
.iter()
.filter(|br| br.flags.is_xref || br.flags.is_xref_overlay)
.map(|br| {
format!(
" {} — {}",
br.name,
if br.xref_path.is_empty() {
"(no path)".to_string()
} else {
br.xref_path.clone()
}
)
})
.collect();
if xrefs.is_empty() {
self.command_line.push_output("XREF No external references in this drawing.");
} else {
self.command_line.push_output("XREF External references:");
for line in xrefs {
self.command_line.push_output(&line);
}
}
}
"XRELOAD" => {
// Reload all xrefs for the current drawing.
if let Some(path) = &self.tabs[i].current_path.clone() {
if let Some(base_dir) = path.parent() {
let infos = crate::io::xref::resolve_xrefs(
&mut self.tabs[i].scene.document,
base_dir,
);
for info in &infos {
match info.status {
crate::io::xref::XrefStatus::Loaded => {
self.command_line.push_output(&format!(
"XREF Reloaded \"{}\"",
info.name
));
}
crate::io::xref::XrefStatus::NotFound => {
self.command_line.push_error(&format!(
"XREF Not found: \"{}\" ({})",
info.name, info.path
));
}
}
}
self.tabs[i].scene.populate_hatches_from_document();
self.tabs[i].scene.populate_images_from_document();
self.tabs[i].scene.populate_meshes_from_document();
}
} else {
self.command_line.push_error("XREF Save the drawing first to resolve relative XREF paths.");
}
}
// ── Draw commands ──────────────────────────────────────────────
"LINE"|"L" => {
use crate::modules::home::draw::line::LineCommand;

View file

@ -412,6 +412,11 @@ pub enum Message {
ImagePick,
/// Result of the image file picker + pixel dimension decode.
ImagePickResult(Result<(std::path::PathBuf, u32, u32), String>),
// ── XREF ──────────────────────────────────────────────────────────────
/// Open file-picker dialog for XATTACH command (async).
XAttachPick,
/// Result of the XATTACH file picker.
XAttachPickResult(Result<std::path::PathBuf, String>),
}
impl H7CAD {

View file

@ -46,8 +46,33 @@ impl H7CAD {
idx
};
self.tabs[i].current_path = Some(path);
self.tabs[i].current_path = Some(path.clone());
self.tabs[i].scene.document = doc;
// Auto-resolve XREFs relative to the opened file's directory.
if let Some(base_dir) = path.parent() {
let xrefs = crate::io::xref::resolve_xrefs(
&mut self.tabs[i].scene.document,
base_dir,
);
for info in &xrefs {
match info.status {
crate::io::xref::XrefStatus::Loaded => {
self.command_line.push_output(&format!(
"XREF Loaded \"{}\"",
info.name
));
}
crate::io::xref::XrefStatus::NotFound => {
self.command_line.push_error(&format!(
"XREF Not found: \"{}\" ({})",
info.name, info.path
));
}
}
}
}
self.tabs[i].scene.populate_hatches_from_document();
self.tabs[i].scene.populate_images_from_document();
self.tabs[i].scene.populate_meshes_from_document();
@ -103,6 +128,41 @@ impl H7CAD {
Task::none()
}
Message::XAttachPick => Task::perform(
async {
let handle = rfd::AsyncFileDialog::new()
.set_title("Select External Reference File")
.add_filter("CAD Files", &["dwg", "dxf", "DWG", "DXF"])
.add_filter("DWG Files", &["dwg", "DWG"])
.add_filter("DXF Files", &["dxf", "DXF"])
.pick_file()
.await;
match handle {
Some(h) => Ok(h.path().to_path_buf()),
None => Err("Cancelled".to_string()),
}
},
Message::XAttachPickResult,
),
Message::XAttachPickResult(Ok(path)) => {
use crate::command::CadCommand;
use crate::modules::insert::xattach::XAttachCommand;
let path_str = path.to_string_lossy().into_owned();
let cmd = XAttachCommand::with_path(path_str);
let i = self.active_tab;
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
Task::none()
}
Message::XAttachPickResult(Err(e)) => {
if e != "Cancelled" {
self.command_line.push_error(&format!("XATTACH: {e}"));
}
Task::none()
}
Message::SaveFile => {
let i = self.active_tab;
if let Some(path) = &self.tabs[i].current_path {

View file

@ -227,6 +227,12 @@ pub trait CadCommand: Send {
false
}
/// If this command is XATTACH, returns the file path to attach.
/// Default: None.
fn xattach_path(&self) -> Option<String> {
None
}
/// Called instead of `on_point` when the command needs a tangent pick
/// and the snap system found a tangent object.
fn on_tangent_point(&mut self, obj: TangentObject, hit: Vec3) -> CmdResult {

View file

@ -5,6 +5,7 @@
pub mod pdf_export;
pub mod plot_style;
pub mod xref;
use acadrust::io::dwg::DwgReader;
use acadrust::{CadDocument, DwgWriter, DxfReader, DxfWriter};

143
src/io/xref.rs Normal file
View file

@ -0,0 +1,143 @@
// XREF resolution — scan a loaded document for external-reference blocks and
// populate them with geometry from the referenced DWG/DXF files.
use acadrust::entities::{Block, BlockEnd};
use acadrust::types::{Handle, Vector3};
use acadrust::{CadDocument, EntityType};
use std::path::{Path, PathBuf};
/// Status of an external reference block.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum XrefStatus {
/// File was found and loaded successfully.
Loaded,
/// File path is set but the file could not be found or read.
NotFound,
}
/// 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,
}
/// 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.
pub fn resolve_xrefs(doc: &mut CadDocument, base_dir: &Path) -> Vec<XrefInfo> {
// Collect xref blocks: (name, raw_path, block_record_handle)
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())
.map(|br| (br.name.clone(), br.xref_path.clone(), br.handle))
.collect();
let mut result = Vec::new();
for (block_name, raw_path, br_handle) in xref_entries {
let resolved = resolve_path(&raw_path, base_dir);
let status = match &resolved {
None => XrefStatus::NotFound,
Some(p) => match super::load_file(p) {
Err(_) => XrefStatus::NotFound,
Ok(xref_doc) => {
ensure_block_entities(doc, &block_name);
merge_xref_into_block(doc, br_handle, xref_doc);
XrefStatus::Loaded
}
},
};
result.push(XrefInfo {
name: block_name,
path: resolved
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or(raw_path),
status,
});
}
result
}
/// Try to build an absolute path from a raw xref path string.
/// Handles absolute paths, relative paths, and Windows-style separators.
fn resolve_path(raw: &str, base_dir: &Path) -> Option<PathBuf> {
let normalised = raw.replace('\\', "/");
let p = PathBuf::from(&normalised);
if p.is_absolute() {
if p.exists() {
return Some(p);
}
// Fallback: try the filename in base_dir.
if let Some(fname) = p.file_name() {
let c = base_dir.join(fname);
if c.exists() {
return Some(c);
}
}
return None;
}
// Relative path against base_dir.
let candidate = base_dir.join(&p);
if candidate.exists() {
return Some(candidate);
}
// Last resort: just the filename.
if let Some(fname) = p.file_name() {
let c = base_dir.join(fname);
if c.exists() {
return Some(c);
}
}
None
}
/// Make sure `doc` has BLOCK + ENDBLK entities for `block_name`.
/// These are required so renderers can find the block content.
fn ensure_block_entities(doc: &mut CadDocument, block_name: &str) {
let has_block = doc.entities().any(|e| {
matches!(e, EntityType::Block(b) if b.name == block_name)
});
if has_block {
return;
}
let b = Block::new(block_name, Vector3::zero());
let _ = doc.add_entity(EntityType::Block(b));
let _ = doc.add_entity(EntityType::BlockEnd(BlockEnd::new()));
}
/// Copy model-space entities from `xref_doc` into the xref block (`br_handle`)
/// of `doc`.
fn merge_xref_into_block(doc: &mut CadDocument, br_handle: Handle, xref_doc: CadDocument) {
let entities: Vec<EntityType> = xref_doc
.entities()
.filter(|e| !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)))
.cloned()
.collect();
for mut entity in entities {
// Clear the foreign handle so acadrust assigns a new one.
set_handle(&mut entity, Handle::NULL);
// Route to the xref block record.
entity.common_mut().owner_handle = br_handle;
let _ = doc.add_entity(entity);
}
}
/// Set the handle field of any entity variant.
fn set_handle(entity: &mut EntityType, h: Handle) {
entity.common_mut().handle = h;
}

View file

@ -7,6 +7,7 @@ mod cylinder;
pub(crate) mod insert_block;
mod open_obj;
mod sphere;
pub(crate) mod xattach;
use crate::modules::{CadModule, RibbonGroup};
@ -36,7 +37,11 @@ impl CadModule for InsertModule {
},
RibbonGroup {
title: "Block",
tools: vec![create_block::tool().into(), insert_block::tool().into()],
tools: vec![
create_block::tool().into(),
insert_block::tool().into(),
xattach::tool().into(),
],
},
]
}

View file

@ -0,0 +1,175 @@
// XATTACH command — attach an external DWG/DXF file as an XREF block
// and insert it at a picked point.
//
// Workflow:
// Step 1 (text input): user types the file path (or the file-picker
// message has already supplied it).
// Step 2 (point pick): user clicks the insertion point.
// Result: BlockRecord + Block entities are created with is_xref=true,
// then an INSERT entity is committed.
use acadrust::entities::{Block, BlockEnd, Insert};
use acadrust::tables::block_record::{BlockFlags, BlockRecord};
use acadrust::types::Vector3;
use acadrust::EntityType;
use glam::Vec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::wire_model::WireModel;
use crate::scene::Scene;
pub fn tool() -> ToolDef {
ToolDef {
id: "XATTACH",
label: "Attach XREF",
icon: IconKind::Svg(include_bytes!("../../../assets/icons/blocks/insert.svg")),
event: ModuleEvent::Command("XATTACH".to_string()),
}
}
#[allow(dead_code)]
enum Step {
/// Waiting for the user to type (or accept) a file path.
FilePath,
/// File path is confirmed; waiting for an insertion point.
InsertionPoint { path: String, block_name: String },
}
pub struct XAttachCommand {
step: Step,
/// Pre-supplied path (from the file-picker message).
prefilled_path: Option<String>,
}
impl XAttachCommand {
#[allow(dead_code)]
pub fn new() -> Self {
Self {
step: Step::FilePath,
prefilled_path: None,
}
}
/// Create an XATTACH command with a path already filled in (from file-picker).
pub fn with_path(path: String) -> Self {
let block_name = path_to_block_name(&path);
Self {
step: Step::InsertionPoint {
path: path.clone(),
block_name,
},
prefilled_path: Some(path),
}
}
}
impl CadCommand for XAttachCommand {
fn name(&self) -> &'static str {
"XATTACH"
}
fn prompt(&self) -> String {
match &self.step {
Step::FilePath => "XATTACH Enter path to external DWG/DXF file:".to_string(),
Step::InsertionPoint { block_name, .. } => {
format!("XATTACH Specify insertion point for \"{}\":", block_name)
}
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::FilePath)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if !matches!(self.step, Step::FilePath) {
return None;
}
let path = text.trim().to_string();
if path.is_empty() {
return None;
}
let block_name = path_to_block_name(&path);
self.step = Step::InsertionPoint { path, block_name };
Some(CmdResult::NeedPoint)
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match &self.step {
Step::FilePath => CmdResult::NeedPoint,
Step::InsertionPoint { path: _, block_name } => {
// We return the INSERT entity; the command handler in
// commands.rs will call `prepare_xref_block` on the scene
// before committing.
CmdResult::CommitAndExit(EntityType::Insert(Insert::new(
block_name.clone(),
Vector3::new(pt.x as f64, pt.y as f64, pt.z as f64),
)))
}
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_preview_wires(&mut self, _pt: Vec3) -> Vec<WireModel> {
vec![]
}
fn xattach_path(&self) -> Option<String> {
match &self.step {
Step::InsertionPoint { path, .. } => Some(path.clone()),
Step::FilePath => self.prefilled_path.clone(),
}
}
}
/// Derive a block name from the file path: take the file stem, uppercase it.
pub fn path_to_block_name(path: &str) -> String {
let p = std::path::Path::new(path);
p.file_stem()
.map(|s| s.to_string_lossy().to_uppercase())
.unwrap_or_else(|| "XREF".to_string())
}
/// Create the XREF BlockRecord + Block/EndBlock entities in the scene document
/// for a given file path. Returns the block name.
///
/// This must be called before committing the INSERT so that the block
/// definition exists when the renderer looks it up.
pub fn prepare_xref_block(scene: &mut Scene, path: &str) -> String {
let block_name = path_to_block_name(path);
// If a BlockRecord already exists with this name, skip creation.
if scene.document.block_records.get(&block_name).is_some() {
return block_name;
}
// Create the BlockRecord.
let mut br = BlockRecord::new(&block_name);
br.handle = scene.document.allocate_handle();
br.flags = BlockFlags {
is_xref: true,
is_xref_overlay: false,
anonymous: false,
has_attributes: false,
is_external: false,
};
br.xref_path = path.to_string();
let _ = scene.document.block_records.add(br);
// Create BLOCK entity.
let b = Block::new(&block_name, Vector3::zero()).with_xref_path(path);
let _ = scene.document.add_entity(EntityType::Block(b));
let _ = scene.document.add_entity(EntityType::BlockEnd(BlockEnd::new()));
// Resolve the XREF content immediately.
let path_buf = std::path::PathBuf::from(path);
if let Some(base_dir) = path_buf.parent() {
crate::io::xref::resolve_xrefs(&mut scene.document, base_dir);
}
block_name
}