Xref attach: Attach dialog, reference prompts, and xref drawings other applications can open

Attach External Reference dialog after XATTACH / ATTACH; ATTACH routes
drawings, PDFs and images; placement prompts and messages follow the
reference (presets, Corner/XYZ, nonzero check, existing definition reuse,
missing file, self-reference); name keeps the file's case; overlay and
path type at attach; one undo step for definition and INSERT; -XREF option
prompt and `?` table; XREF opens the palette; ER alias; xref Properties
rows; dependent symbols flagged and remapped and resolved content left out
of the saved file; dialog_ok automation action; localized labels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ramox81andClaude Opus 5.5 committed 2026-09-23 14:44:12 +03:00
1 parent f2d534bf71
commit 6bd55bc32e
44 files changed
+2514 -359

No files matched your search

+1
View File
@@ -23,6 +23,7 @@ mod underlay_layers;
pub(crate) mod wblock;
mod xadjust;
pub(crate) mod xattach;
pub(crate) mod xref_cmd;
mod xclip;
use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem};
+526 -249
View File
@@ -1,23 +1,25 @@
// XATTACH command — attach an external DWG/DXF file as an XREF block
// and insert it at a picked point.
// XATTACH placement — attach an external DWG/DXF file as an XREF block and
// insert it.
//
// Workflow:
// Step 1 (point pick): user clicks the insertion point.
// Step 2 (text input): scale factor (default 1.0).
// Step 3 (text input): rotation angle (default 0.0).
// Result: BlockRecord + Block entities are created with is_xref=true,
// then an INSERT entity is committed with the chosen scale/rotation.
// The reference options (type, path type, fixed or on-screen scale,
// insertion point and rotation) come from the Attach External Reference
// dialog or from -XREF Attach / Overlay. Values left "on-screen" are asked
// here with the reference prompts:
// Specify insertion point or [Scale/X/Y/Z/Rotate/PScale/PX/PY/PZ/PRotate]:
// Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>:
// Enter Y scale factor <use X scale factor>:
// Specify rotation angle <0>:
// The definition is created when the INSERT is committed (see the command
// driver), so one undo removes both.
use acadrust::entities::{Block, BlockEnd, Insert};
use acadrust::entities::Insert;
use acadrust::tables::block_record::{BlockFlags, BlockRecord};
use acadrust::types::Vector3;
use acadrust::EntityType;
use glam::DVec3;
use crate::t;
use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane};
use crate::io::xref_model::normalize_lexical;
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, WorkingPlane};
use crate::io::xref_model::{normalize_lexical, Pathtype};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use crate::scene::Scene;
@@ -31,45 +33,210 @@ pub fn tool() -> ToolDef {
}
}
/// Multi-step placement state: insertion point, then scale, then rotation.
/// Mirrors the INSERT command's scale/rotation value handling (single value
/// typed after the point pick, bare Enter keeps the default).
enum XAttachStep {
Point,
Scale { point: DVec3 },
Rotate { point: DVec3, sx: f64, sy: f64, sz: f64 },
/// What to attach and how the reference is stored.
#[derive(Debug, Clone, PartialEq)]
pub struct XrefAttachRequest {
/// The drawing file as chosen (absolute).
pub path: String,
pub overlay: bool,
pub path_type: Pathtype,
}
pub struct XAttachCommand {
path: String,
block_name: String,
plane: WorkingPlane,
step: XAttachStep,
/// Placement values fixed before the command starts; `None` = on-screen.
#[derive(Debug, Clone, Copy, Default)]
pub struct XrefPlacement {
pub insert: Option<DVec3>,
pub scale: Option<[f64; 3]>,
/// Degrees.
pub rotation: Option<f64>,
}
impl XAttachCommand {
/// 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 {
path,
block_name,
plane: WorkingPlane::default(),
step: XAttachStep::Point,
impl XrefPlacement {
/// Everything on-screen, as -XREF asks it.
pub fn on_screen() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Preset {
Scale,
X,
Y,
Z,
Rotate,
PScale,
PX,
PY,
PZ,
PRotate,
}
impl Preset {
fn from_keyword(text: &str) -> Option<Self> {
Some(match text {
"S" | "SCALE" => Preset::Scale,
"X" => Preset::X,
"Y" => Preset::Y,
"Z" => Preset::Z,
"R" | "ROTATE" => Preset::Rotate,
"PS" | "PSCALE" => Preset::PScale,
"PX" => Preset::PX,
"PY" => Preset::PY,
"PZ" => Preset::PZ,
"PR" | "PROTATE" => Preset::PRotate,
_ => return None,
})
}
fn prompt(self) -> &'static str {
match self {
Preset::Scale => "Specify scale factor for XYZ axes:",
Preset::X => "Specify X scale factor:",
Preset::Y => "Specify Y scale factor:",
Preset::Z => "Specify Z Scale factor:",
Preset::Rotate => "Specify rotation angle:",
Preset::PScale => "Specify preview scale factor for XYZ axes:",
Preset::PX => "Specify preview X scale factor:",
Preset::PY => "Specify preview Y scale factor:",
Preset::PZ => "Specify preview Z scale factor:",
Preset::PRotate => "Specify preview rotation angle:",
}
}
fn commit_insert(&self, point: DVec3, sx: f64, sy: f64, sz: f64, rotation: f64) -> EntityType {
fn is_angle(self) -> bool {
matches!(self, Preset::Rotate | Preset::PRotate)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum Step {
Insert,
Preset(Preset),
XScale,
Corner,
YScale,
XyzX,
XyzY,
XyzZ,
Rotation,
}
pub struct XAttachCommand {
request: XrefAttachRequest,
block_name: String,
/// Command name shown before each prompt (ATTACH, XATTACH or -XREF).
label: &'static str,
plane: WorkingPlane,
step: Step,
point: Option<DVec3>,
scale: [f64; 3],
/// Scale fixed in the dialog or by the Scale/X/Y/Z options.
scale_fixed: bool,
/// Degrees.
rotation: f64,
rotation_fixed: bool,
}
const NONZERO: &str = "Value must be nonzero.";
impl XAttachCommand {
pub fn new(request: XrefAttachRequest, placement: XrefPlacement, label: &'static str) -> Self {
let block_name = path_to_block_name(&request.path);
let mut command = Self {
request,
block_name,
label,
plane: WorkingPlane::default(),
step: Step::Insert,
point: placement.insert,
scale: placement.scale.unwrap_or([1.0; 3]),
scale_fixed: placement.scale.is_some(),
rotation: placement.rotation.unwrap_or(0.0),
rotation_fixed: placement.rotation.is_some(),
};
if command.point.is_some() {
command.step = command.next_step().unwrap_or(Step::Rotation);
}
command
}
/// Attach with every value on-screen.
#[cfg(test)]
pub fn with_path(path: String) -> Self {
Self::new(
XrefAttachRequest {
path,
overlay: false,
path_type: Pathtype::Full,
},
XrefPlacement::on_screen(),
"XATTACH",
)
}
/// The finished INSERT when nothing is left to ask (all values fixed in
/// the dialog).
pub fn immediate(&self) -> Option<EntityType> {
(self.point.is_some() && self.next_step().is_none()).then(|| self.commit_insert())
}
/// The step after the insertion point, or `None` when it can commit.
fn next_step(&self) -> Option<Step> {
if !self.scale_fixed {
Some(Step::XScale)
} else if !self.rotation_fixed {
Some(Step::Rotation)
} else {
None
}
}
fn advance(&mut self) -> CmdResult {
match self.next_step() {
Some(step) => {
self.step = step;
CmdResult::NeedPoint
}
None => CmdResult::CommitAndExit(self.commit_insert()),
}
}
/// Scale settled at this step: the rotation (if asked) comes next.
fn scale_done(&mut self) -> CmdResult {
self.scale_fixed = true;
self.advance()
}
fn commit_insert(&self) -> EntityType {
let point = self.point.unwrap_or(DVec3::ZERO);
let mut ins = Insert::new(
self.block_name.clone(),
Vector3::new(point.x, point.y, point.z),
);
ins.set_x_scale(sx);
ins.set_y_scale(sy);
ins.set_z_scale(sz);
ins.rotation = rotation;
ins.set_x_scale(self.scale[0]);
ins.set_y_scale(self.scale[1]);
ins.set_z_scale(self.scale[2]);
ins.rotation = self.rotation.to_radians();
self.plane.place_entity(EntityType::Insert(ins))
}
/// Corner pick at the X scale prompt: X and Y from the rectangle,
/// Z follows X.
fn corner(&mut self, pt: DVec3) -> CmdResult {
let base = self.point.unwrap_or(DVec3::ZERO);
let corner = self.plane.to_local(pt);
let (sx, sy) = (corner.x - base.x, corner.y - base.y);
if sx.abs() < 1e-12 || sy.abs() < 1e-12 {
return CmdResult::ReportError(NONZERO.to_string());
}
self.scale = [sx, sy, sx.abs()];
self.scale_done()
}
}
fn parse_number(text: &str) -> Option<f64> {
text.trim().replace(',', ".").parse::<f64>().ok().filter(|v| v.is_finite())
}
impl CadCommand for XAttachCommand {
@@ -82,121 +249,228 @@ impl CadCommand for XAttachCommand {
}
fn prompt(&self) -> String {
match &self.step {
XAttachStep::Point => t!(
"XATTACH Specify insertion point for \"%{name}\":",
name = self.block_name
)
.into_owned(),
XAttachStep::Scale { .. } => {
t!("XATTACH Specify scale factor <1.0> (X,Y,Z or pick Corner):").into_owned()
let text = match self.step {
Step::Insert => {
"Specify insertion point or [Scale/X/Y/Z/Rotate/PScale/PX/PY/PZ/PRotate]:"
}
XAttachStep::Rotate { .. } => {
t!("XATTACH Specify rotation angle <0>:").into_owned()
Step::Preset(preset) => preset.prompt(),
Step::XScale => {
"Enter X scale factor, specify opposite corner, or [Corner/XYZ] <1>:"
}
Step::Corner => "Specify opposite corner:",
Step::YScale => "Enter Y scale factor <use X scale factor>:",
Step::XyzX => "Specify X scale factor or [Corner] <1>:",
Step::XyzY => "Enter Y scale factor <use X scale factor>:",
Step::XyzZ => "Specify Z scale factor or <use X scale factor>:",
Step::Rotation => "Specify rotation angle <0>:",
};
format!("{} {}", self.label, text)
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::Insert => vec![
CmdOption::new("Scale", "S"),
CmdOption::new("X", "X"),
CmdOption::new("Y", "Y"),
CmdOption::new("Z", "Z"),
CmdOption::new("Rotate", "R"),
CmdOption::new("PScale", "PS"),
CmdOption::new("PX", "PX"),
CmdOption::new("PY", "PY"),
CmdOption::new("PZ", "PZ"),
CmdOption::new("PRotate", "PR"),
],
Step::XScale => vec![CmdOption::new("Corner", "C"), CmdOption::new("XYZ", "XYZ")],
Step::XyzX => vec![CmdOption::new("Corner", "C")],
_ => Vec::new(),
}
}
fn input_kind(&self) -> InputKind {
match self.step {
XAttachStep::Point => InputKind::Point,
XAttachStep::Scale { .. } | XAttachStep::Rotate { .. } => InputKind::SingleToken,
Step::Insert | Step::Corner => InputKind::Point,
_ => InputKind::SingleToken,
}
}
fn point_step_accepts_keywords(&self) -> bool {
self.step == Step::Insert
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.step {
XAttachStep::Point => {
let point = self.plane.to_local(pt);
self.step = XAttachStep::Scale { point };
CmdResult::NeedPoint
Step::Insert => {
self.point = Some(self.plane.to_local(pt));
self.advance()
}
XAttachStep::Scale { point } => {
// Corner: derive X/Y scale from the picked opposite corner.
let corner = self.plane.to_local(pt);
let sx = (corner.x - point.x).abs();
let sy = (corner.y - point.y).abs();
self.step = XAttachStep::Rotate {
point,
sx: if sx < 1e-9 { 1.0 } else { sx },
sy: if sy < 1e-9 { 1.0 } else { sy },
sz: 1.0,
};
CmdResult::NeedPoint
}
XAttachStep::Rotate { point, sx, sy, sz } => {
// Second point defines the rotation direction from the base.
let base_world = self.plane.to_world(point);
let rotation = self.plane.angle(base_world, pt).unwrap_or(0.0);
CmdResult::CommitAndExit(self.commit_insert(point, sx, sy, sz, rotation))
Step::XScale | Step::Corner | Step::XyzX => self.corner(pt),
Step::Rotation => {
let base = self.plane.to_world(self.point.unwrap_or(DVec3::ZERO));
self.rotation = self.plane.angle(base, pt).unwrap_or(0.0).to_degrees();
CmdResult::CommitAndExit(self.commit_insert())
}
_ => CmdResult::NeedPoint,
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let token = text.trim().to_uppercase();
match self.step {
XAttachStep::Point => None,
XAttachStep::Scale { point } => {
// Single value (uniform) or X/Y/Z triplet; invalid input is
// consumed and re-prompts (mirrors INSERT's scale handling).
if let Some((sx, sy, sz)) = parse_scale_spec(text) {
self.step = XAttachStep::Rotate { point, sx, sy, sz };
}
Step::Insert => {
let preset = Preset::from_keyword(&token)?;
self.step = Step::Preset(preset);
Some(CmdResult::NeedPoint)
}
XAttachStep::Rotate { point, sx, sy, sz } => {
if let Some(rotation) = crate::entities::common::parse_typed_angle(text) {
Some(CmdResult::CommitAndExit(
self.commit_insert(point, sx, sy, sz, rotation),
))
Step::Preset(preset) => {
let value = if preset.is_angle() {
crate::entities::common::parse_typed_angle(text).map(f64::to_degrees)
} else {
Some(CmdResult::NeedPoint)
parse_number(text)
};
let Some(value) = value else {
return Some(CmdResult::NeedPoint);
};
if !preset.is_angle() && value == 0.0 {
return Some(CmdResult::ReportError(NONZERO.to_string()));
}
match preset {
Preset::Scale => {
self.scale = [value; 3];
self.scale_fixed = true;
}
Preset::X => {
self.scale[0] = value;
self.scale_fixed = true;
}
Preset::Y => {
self.scale[1] = value;
self.scale_fixed = true;
}
Preset::Z => {
self.scale[2] = value;
self.scale_fixed = true;
}
Preset::Rotate => {
self.rotation = value;
self.rotation_fixed = true;
}
// Preview-only values: the reference asks the real ones
// after the insertion point as usual.
Preset::PScale | Preset::PX | Preset::PY | Preset::PZ | Preset::PRotate => {}
}
self.step = Step::Insert;
Some(CmdResult::NeedPoint)
}
Step::XScale | Step::XyzX if token == "C" || token == "CORNER" => {
self.step = Step::Corner;
Some(CmdResult::NeedPoint)
}
Step::XScale if token == "XYZ" => {
self.step = Step::XyzX;
Some(CmdResult::NeedPoint)
}
Step::XScale | Step::XyzX => {
let value = parse_number(text)?;
if value == 0.0 {
return Some(CmdResult::ReportError(NONZERO.to_string()));
}
self.scale = [value, value, value.abs()];
self.step = if self.step == Step::XScale {
Step::YScale
} else {
Step::XyzY
};
Some(CmdResult::NeedPoint)
}
Step::YScale | Step::XyzY => {
let value = parse_number(text)?;
if value == 0.0 {
return Some(CmdResult::ReportError(NONZERO.to_string()));
}
self.scale[1] = value;
if self.step == Step::XyzY {
self.step = Step::XyzZ;
return Some(CmdResult::NeedPoint);
}
Some(self.scale_done())
}
Step::XyzZ => {
let value = parse_number(text)?;
if value == 0.0 {
return Some(CmdResult::ReportError(NONZERO.to_string()));
}
self.scale[2] = value;
Some(self.scale_done())
}
Step::Rotation => {
let rotation = crate::entities::common::parse_typed_angle(text)?;
self.rotation = rotation.to_degrees();
Some(CmdResult::CommitAndExit(self.commit_insert()))
}
Step::Corner => None,
}
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
// Bare Enter at the insertion-point step cancels (unchanged).
XAttachStep::Point => CmdResult::Cancel,
// Bare Enter accepts the default scale / rotation.
XAttachStep::Scale { point } => {
self.step = XAttachStep::Rotate {
point,
sx: 1.0,
sy: 1.0,
sz: 1.0,
Step::Insert => {
CmdResult::ReportError("Point or option keyword required.".to_string())
}
Step::Preset(_) => {
self.step = Step::Insert;
CmdResult::NeedPoint
}
Step::XScale | Step::XyzX => {
self.scale = [1.0; 3];
self.step = if self.step == Step::XScale {
Step::YScale
} else {
Step::XyzY
};
CmdResult::NeedPoint
}
XAttachStep::Rotate { point, sx, sy, sz } => {
CmdResult::CommitAndExit(self.commit_insert(point, sx, sy, sz, 0.0))
Step::YScale => {
self.scale[1] = self.scale[0];
self.scale_done()
}
Step::XyzY => {
self.scale[1] = self.scale[0];
self.step = Step::XyzZ;
CmdResult::NeedPoint
}
Step::XyzZ => {
self.scale[2] = self.scale[0];
self.scale_done()
}
Step::Corner => CmdResult::NeedPoint,
Step::Rotation => {
self.rotation = 0.0;
CmdResult::CommitAndExit(self.commit_insert())
}
}
}
fn on_preview_wires(&mut self, _pt: DVec3) -> Vec<WireModel> {
// XREF-Task6: PScale drag preview — no framework hook; revisit
vec![]
}
fn xattach_path(&self) -> Option<String> {
Some(self.path.clone())
fn xattach_request(&self) -> Option<XrefAttachRequest> {
Some(self.request.clone())
}
}
/// Derive a block name from the file path: take the file stem, uppercase it.
/// Reference name for a file: its name without the extension, case kept.
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())
let normalized = path.replace('\\', "/");
std::path::Path::new(&normalized)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "XREF".to_string())
}
/// Collision-free block name: `stem` unless taken (case-insensitive — block
/// names are uppercase by convention), else `stem_1`, `stem_2`, ...
/// (`$0$` numbering belongs to Bind, not Attach.)
/// Collision-free block name: `stem` unless taken (case-insensitive), else
/// `stem_1`, `stem_2`, ... (`$0$` numbering belongs to Bind, not Attach.)
pub fn unique_block_name(stem: &str, taken: &[impl AsRef<str>]) -> String {
let upper_taken: Vec<String> = taken.iter().map(|t| t.as_ref().to_uppercase()).collect();
if !upper_taken.iter().any(|t| t == &stem.to_uppercase()) {
@@ -215,10 +489,6 @@ pub fn unique_block_name(stem: &str, taken: &[impl AsRef<str>]) -> String {
}
}
/// Attach-default-Full store path: a relative incoming path is joined onto
/// the host drawing's base dir (yielding an absolute path when the base dir
/// is absolute); absolute paths and missing base dirs pass through verbatim.
/// Separators are normalized to `/` for stable DWG storage.
fn is_absolute_xref_path(path: &str) -> bool {
let bytes = path.as_bytes();
std::path::Path::new(path).is_absolute()
@@ -229,6 +499,9 @@ fn is_absolute_xref_path(path: &str) -> bool {
&& matches!(bytes[2], b'/' | b'\\'))
}
/// Full store path: a relative incoming path is joined onto the host
/// drawing's base dir; absolute paths and missing base dirs pass through.
/// Separators are normalized to `/` for stable DWG storage.
pub fn resolve_xref_store_path(path: &str, host_base_dir: Option<&std::path::Path>) -> String {
let p = std::path::Path::new(path);
if is_absolute_xref_path(path) {
@@ -240,14 +513,6 @@ pub fn resolve_xref_store_path(path: &str, host_base_dir: Option<&std::path::Pat
}
}
// Host-path note (XREF-Task6): `Scene` exposes no host file path — it owns
// the document plus view state only (verified in scene/mod.rs: no file path
// field). The host drawing path lives one layer up, in `Tab::current_path`
// (app/document.rs). The self-attach guard therefore runs at the command
// driver layer, which owns both the tab path and the pending XATTACH path,
// via this pure, unit-tested helper. `prepare_xref_block` below stays
// side-effect-only and keeps its `-> String` return.
/// True when attaching `ref_path` would attach the host drawing into itself.
/// Lexical comparison only (no filesystem touch — the target may be missing);
/// a relative ref is resolved against `host_base_dir` (falling back to the
@@ -271,46 +536,52 @@ pub fn is_self_attach(
normalize_lexical(&candidate) == normalize_lexical(&host_file.to_string_lossy())
}
/// Parse a typed scale: a single uniform value, or an X/Y/Z triplet separated
/// by commas, semicolons or whitespace. Zero never scales (rejected, like
/// INSERT); unparseable input returns None and the caller re-prompts.
fn parse_scale_spec(text: &str) -> Option<(f64, f64, f64)> {
let norm = text.trim().replace([',', ';'], " ");
let parts: Vec<&str> = norm.split_whitespace().collect();
match parts.as_slice() {
[single] => single
.parse::<f64>()
.ok()
.filter(|v| *v != 0.0)
.map(|v| (v, v, 1.0)),
[x, y, z] => {
let parsed: Option<Vec<f64>> =
[x, y, z].iter().map(|s| s.parse::<f64>().ok()).collect();
match parsed {
Some(v) if v.iter().all(|n| *n != 0.0) => Some((v[0], v[1], v[2])),
_ => None,
}
}
_ => None,
/// The drawing's existing reference for `name`, if any (case-insensitive).
pub fn existing_reference(scene: &Scene, name: &str) -> Option<String> {
scene
.document
.block_records
.iter()
.find(|br| {
(br.flags.is_xref || br.flags.is_xref_overlay) && br.name.eq_ignore_ascii_case(name)
})
.map(|br| br.name.clone())
}
/// The path to store for `request`: full, relative to the saved host, or
/// the file name alone. A relative path needs a saved host; until then the
/// full path is kept, as the reference does.
pub fn stored_path(request: &XrefAttachRequest, host_file: Option<&std::path::Path>) -> String {
let full = resolve_xref_store_path(&request.path, None);
match request.path_type {
Pathtype::Full => full,
Pathtype::Relative => host_file
.and_then(|host| {
crate::io::xref_model::to_pathtype_result(&full, host, Pathtype::Relative).ok()
})
.unwrap_or(full),
Pathtype::None => std::path::Path::new(&full.replace('\\', "/"))
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or(full),
}
}
/// Create the XREF BlockRecord + Block/EndBlock entities in the scene document
/// for a given file path. Returns the block name.
/// Create (or reuse) the reference definition for `request` and load its
/// content. Returns the block name the INSERT must use.
///
/// This must be called before committing the INSERT so that the block
/// definition exists when the renderer looks it up.
///
/// Name collisions get a `_1`, `_2`... suffix via [`unique_block_name`]; the
/// stored path follows Attach-default-Full via [`resolve_xref_store_path`].
/// Self-attach blocking is NOT done here — see the note on [`is_self_attach`];
/// the driver checks it before calling.
pub fn prepare_xref_block(
/// A reference of the same name already in the drawing is reused ("Using
/// existing definition."). The self-attach guard runs before this, where the
/// host path is known.
pub fn prepare_xref_definition(
scene: &mut Scene,
path: &str,
host_base_dir: Option<&std::path::Path>,
request: &XrefAttachRequest,
host_file: Option<&std::path::Path>,
) -> String {
let stem = path_to_block_name(path);
let stem = path_to_block_name(&request.path);
if let Some(existing) = existing_reference(scene, &stem) {
return existing;
}
let taken: Vec<String> = scene
.document
.block_records
@@ -318,20 +589,13 @@ pub fn prepare_xref_block(
.map(|b| b.name.clone())
.collect();
let block_name = unique_block_name(&stem, &taken);
let store_path = stored_path(request, host_file);
// If a BlockRecord already exists with this name, skip creation.
if scene.document.block_records.get(&block_name).is_some() {
return block_name;
}
let store_path = resolve_xref_store_path(path, host_base_dir);
// 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,
is_xref: !request.overlay,
is_xref_overlay: request.overlay,
anonymous: false,
has_attributes: false,
is_external: false,
@@ -339,33 +603,50 @@ pub fn prepare_xref_block(
is_xref_unloaded: false,
};
br.xref_path = store_path.clone();
let key = br.handle;
let _ = scene.document.block_records.add(br);
// Create BLOCK entity.
let b = Block::new(&block_name, Vector3::zero()).with_xref_path(&store_path);
let _ = scene.document.add_entity(EntityType::Block(b));
let _ = scene
.document
.add_entity(EntityType::BlockEnd(BlockEnd::new()));
// BLOCK / ENDBLK markers linked through the record.
crate::io::xref::ensure_block_entities(&mut scene.document, &block_name);
// Resolve the XREF content immediately.
// Every reference in this host resolves relative to the host drawing, not
// relative to the newly-attached file. Using `store_path.parent()` here
// made an attach unexpectedly redirect existing relative xrefs.
if let Some(base_dir) = host_base_dir {
let _ = crate::io::xref::resolve_xrefs(&mut scene.document, base_dir);
} else if let Some(base_dir) = std::path::Path::new(&store_path).parent() {
let _ = crate::io::xref::resolve_xrefs(&mut scene.document, base_dir);
// Load this reference only. A stored relative path resolves against the
// host folder; a full path or bare file name against the chosen file's.
let file_dir = std::path::Path::new(&resolve_xref_store_path(&request.path, None))
.parent()
.map(|p| p.to_path_buf());
let base_dir = match request.path_type {
Pathtype::Relative => host_file.and_then(|h| h.parent()).map(|p| p.to_path_buf()),
_ => None,
}
.or(file_dir);
if let Some(base_dir) = base_dir {
let keys: rustc_hash::FxHashSet<acadrust::types::Handle> = [key].into_iter().collect();
let _ = crate::io::xref::resolve_xrefs_for_keys(&mut scene.document, &base_dir, &keys);
}
block_name
}
/// Full-path attach of `path` (kept for callers that only have a path).
#[cfg(test)]
pub fn prepare_xref_block(
scene: &mut Scene,
path: &str,
host_base_dir: Option<&std::path::Path>,
) -> String {
let request = XrefAttachRequest {
path: resolve_xref_store_path(path, host_base_dir),
overlay: false,
path_type: Pathtype::Full,
};
prepare_xref_definition(scene, &request, None)
}
#[cfg(test)]
mod tests {
use super::{
is_self_attach, parse_scale_spec, path_to_block_name, resolve_xref_store_path,
unique_block_name, XAttachCommand,
is_self_attach, path_to_block_name, resolve_xref_store_path, unique_block_name,
XAttachCommand,
};
use crate::command::{CadCommand, CmdResult};
use acadrust::EntityType;
@@ -385,8 +666,9 @@ mod tests {
}
#[test]
fn block_name_derives_from_stem() {
assert_eq!(path_to_block_name("C:/refs/plan.dwg"), "PLAN");
fn block_name_keeps_the_file_name_case() {
assert_eq!(path_to_block_name("C:/refs/plan.dwg"), "plan");
assert_eq!(path_to_block_name("C:\\refs\\Site Plan.dwg"), "Site Plan");
}
#[test]
@@ -407,96 +689,91 @@ mod tests {
fn self_attach_guard_matches_normalized_paths() {
let host = std::path::Path::new("C:/Drawings/host.dwg");
let base = std::path::Path::new("C:/Drawings");
// Verbatim, drive-letter case, dot segments, and base-relative forms.
assert!(is_self_attach(host, "C:/Drawings/host.dwg", Some(base)));
assert!(is_self_attach(host, "c:/Drawings/host.dwg", Some(base)));
assert!(is_self_attach(host, "C:/Drawings/./host.dwg", Some(base)));
assert!(is_self_attach(host, "host.dwg", Some(base)));
assert!(is_self_attach(host, "C:/Drawings/host.dwg", None));
// Anything else attaches freely.
assert!(!is_self_attach(host, "C:/Drawings/other.dwg", Some(base)));
assert!(!is_self_attach(host, "other.dwg", Some(base)));
assert!(!is_self_attach(host, "other.dwg", None));
}
#[test]
fn scale_spec_parses_single_and_triplet() {
assert_eq!(parse_scale_spec("2"), Some((2.0, 2.0, 1.0)));
assert_eq!(parse_scale_spec("2,3,4"), Some((2.0, 3.0, 4.0)));
assert_eq!(parse_scale_spec("2 3 4"), Some((2.0, 3.0, 4.0)));
assert_eq!(parse_scale_spec("0"), None);
assert_eq!(parse_scale_spec("abc"), None);
assert_eq!(parse_scale_spec("1,2"), None);
fn enter_at_insertion_point_asks_again() {
let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string());
assert!(matches!(cmd.on_enter(), CmdResult::ReportError(_)));
assert!(cmd.prompt().contains("Specify insertion point"));
}
#[test]
fn enter_at_insertion_point_cancels() {
let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string());
assert!(matches!(cmd.on_enter(), CmdResult::Cancel));
}
#[test]
fn placement_flow_scale_then_rotate_commits() {
let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string());
assert!(matches!(
cmd.on_point(DVec3::new(10.0, 20.0, 0.0)),
CmdResult::NeedPoint
));
assert!(cmd.prompt().contains("scale"));
assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::NeedPoint)));
assert!(cmd.prompt().contains("rotation"));
match cmd.on_enter() {
CmdResult::CommitAndExit(EntityType::Insert(ins)) => {
assert_eq!(ins.block_name, "PLAN");
assert!((ins.x_scale() - 2.0).abs() < 1e-9);
assert!((ins.y_scale() - 2.0).abs() < 1e-9);
assert!(ins.rotation.abs() < 1e-9);
assert!((ins.insert_point.x - 10.0).abs() < 1e-9);
assert!((ins.insert_point.y - 20.0).abs() < 1e-9);
}
fn committed(result: Option<CmdResult>) -> acadrust::entities::Insert {
match result {
Some(CmdResult::CommitAndExit(EntityType::Insert(ins))) => ins,
_ => panic!("expected CommitAndExit(Insert)"),
}
}
#[test]
fn placement_flow_triplet_and_typed_rotation() {
fn x_scale_then_default_y_and_rotation() {
let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string());
assert!(matches!(
cmd.on_point(DVec3::ZERO),
CmdResult::NeedPoint
));
assert!(matches!(
cmd.on_text_input("2,3,4"),
Some(CmdResult::NeedPoint)
));
match cmd.on_text_input("90") {
Some(CmdResult::CommitAndExit(EntityType::Insert(ins))) => {
assert!((ins.x_scale() - 2.0).abs() < 1e-9);
assert!((ins.y_scale() - 3.0).abs() < 1e-9);
assert!((ins.z_scale() - 4.0).abs() < 1e-9);
assert!((ins.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
}
_ => panic!("expected CommitAndExit(Insert)"),
}
assert!(matches!(cmd.on_point(DVec3::new(10.0, 20.0, 0.0)), CmdResult::NeedPoint));
assert!(cmd.prompt().contains("Enter X scale factor"));
assert!(matches!(cmd.on_text_input("-2"), Some(CmdResult::NeedPoint)));
assert!(cmd.prompt().contains("Enter Y scale factor"));
assert!(matches!(cmd.on_enter(), CmdResult::NeedPoint));
assert!(cmd.prompt().contains("Specify rotation angle <0>"));
let ins = committed(Some(cmd.on_enter()));
assert_eq!(ins.block_name, "plan");
assert!((ins.x_scale() + 2.0).abs() < 1e-9);
assert!((ins.y_scale() + 2.0).abs() < 1e-9);
// Z follows the size of X, not its sign.
assert!((ins.z_scale() - 2.0).abs() < 1e-9);
assert!((ins.insert_point.x - 10.0).abs() < 1e-9);
}
#[test]
fn placement_flow_corner_pick_sets_xy_scale() {
fn zero_scale_is_refused() {
let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string());
assert!(matches!(
cmd.on_point(DVec3::ZERO),
CmdResult::NeedPoint
));
assert!(matches!(
cmd.on_point(DVec3::new(3.0, 4.0, 0.0)),
CmdResult::NeedPoint
));
match cmd.on_enter() {
CmdResult::CommitAndExit(EntityType::Insert(ins)) => {
assert!((ins.x_scale() - 3.0).abs() < 1e-9);
assert!((ins.y_scale() - 4.0).abs() < 1e-9);
}
_ => panic!("expected CommitAndExit(Insert)"),
}
cmd.on_point(DVec3::ZERO);
assert!(matches!(cmd.on_text_input("0"), Some(CmdResult::ReportError(_))));
assert!(cmd.prompt().contains("Enter X scale factor"));
}
#[test]
fn xyz_option_takes_three_factors() {
let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string());
cmd.on_point(DVec3::ZERO);
cmd.on_text_input("XYZ");
cmd.on_text_input("2");
cmd.on_text_input("3");
cmd.on_text_input("4");
let ins = committed(cmd.on_text_input("90"));
assert!((ins.x_scale() - 2.0).abs() < 1e-9);
assert!((ins.y_scale() - 3.0).abs() < 1e-9);
assert!((ins.z_scale() - 4.0).abs() < 1e-9);
assert!((ins.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
}
#[test]
fn corner_pick_sets_x_and_y() {
let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string());
cmd.on_point(DVec3::ZERO);
assert!(matches!(cmd.on_point(DVec3::new(5.0, 7.0, 0.0)), CmdResult::NeedPoint));
let ins = committed(Some(cmd.on_enter()));
assert!((ins.x_scale() - 5.0).abs() < 1e-9);
assert!((ins.y_scale() - 7.0).abs() < 1e-9);
assert!((ins.z_scale() - 5.0).abs() < 1e-9);
}
#[test]
fn scale_preset_skips_the_scale_prompts() {
let mut cmd = XAttachCommand::with_path("C:/refs/plan.dwg".to_string());
cmd.on_text_input("S");
assert!(cmd.prompt().contains("Specify scale factor for XYZ axes"));
cmd.on_text_input("2");
cmd.on_point(DVec3::ZERO);
assert!(cmd.prompt().contains("Specify rotation angle <0>"));
let ins = committed(Some(cmd.on_enter()));
assert!((ins.z_scale() - 2.0).abs() < 1e-9);
}
}
+221
View File
@@ -0,0 +1,221 @@
// -XREF — the command-line form of the reference manager.
//
// Enter an option [?/Bind/Detach/Path/pathType/Unload/Reload/Overlay/Attach/Show] <Attach>:
//
// Each option collects its answers with the reference prompts and hands them
// to the existing reference operations through `CmdResult::Dispatch`.
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Step {
Option,
List,
Names(&'static str),
File { overlay: bool },
PathNames,
NewPath { name: String },
PathTypeNames,
PathTypeKind { names: String },
ShowNames,
}
pub struct XrefCommand {
step: Step,
/// Saved paths by reference name, for the Path option's "Old path:" line.
saved_paths: Vec<(String, String)>,
}
impl XrefCommand {
pub fn new(saved_paths: Vec<(String, String)>) -> Self {
Self {
step: Step::Option,
saved_paths,
}
}
fn choose(&mut self, token: &str) -> CmdResult {
self.step = match token {
"?" => Step::List,
"B" | "BIND" => Step::Names("Bind"),
"D" | "DETACH" => Step::Names("Detach"),
"P" | "PATH" => Step::PathNames,
"T" | "PATHTYPE" => Step::PathTypeNames,
"U" | "UNLOAD" => Step::Names("Unload"),
"R" | "RELOAD" => Step::Names("Reload"),
"O" | "OVERLAY" => Step::File { overlay: true },
"A" | "ATTACH" => Step::File { overlay: false },
"S" | "SHOW" => Step::ShowNames,
_ => {
return CmdResult::ReportError("Invalid option keyword.".to_string());
}
};
CmdResult::NeedPoint
}
}
impl CadCommand for XrefCommand {
fn name(&self) -> &'static str {
"-XREF"
}
fn prompt(&self) -> String {
let text = match &self.step {
Step::Option => "Enter an option [?/Bind/Detach/Path/pathType/Unload/Reload/Overlay/Attach/Show] <Attach>:",
Step::List => "Enter xref name(s) to list <*>:",
Step::Names("Bind") => "Enter xref name(s) to bind:",
Step::Names("Detach") => "Enter xref name(s) to detach:",
Step::Names("Unload") => "Enter xref name(s) to unload:",
Step::Names(_) => "Enter xref name(s) to reload:",
Step::File { overlay: false } => "Enter name of file to attach:",
Step::File { overlay: true } => "Enter name of file to overlay:",
Step::PathNames => "Edit xref name(s) to edit path:",
Step::NewPath { .. } => "Enter new path:",
Step::PathTypeNames => "Enter xref name(s) to edit path type:",
Step::PathTypeKind { .. } => "Enter new path type [Full/Relative/None]:",
Step::ShowNames => "Enter xref name(s) to show:",
};
format!("-XREF {text}")
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::Option => vec![
CmdOption::new("?", "?"),
CmdOption::new("Bind", "B"),
CmdOption::new("Detach", "D"),
CmdOption::new("Path", "P"),
CmdOption::new("pathType", "T"),
CmdOption::new("Unload", "U"),
CmdOption::new("Reload", "R"),
CmdOption::new("Overlay", "O"),
CmdOption::new("Attach", "A"),
CmdOption::new("Show", "S"),
],
Step::PathTypeKind { .. } => vec![
CmdOption::new("Full", "F"),
CmdOption::new("Relative", "R"),
CmdOption::new("None", "N"),
],
_ => Vec::new(),
}
}
fn input_kind(&self) -> InputKind {
match self.step {
// Paths and names can hold spaces.
Step::File { .. } | Step::NewPath { .. } => InputKind::FreeText,
_ => InputKind::SingleToken,
}
}
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let value = text.trim().trim_matches('"').to_string();
let token = value.to_uppercase();
Some(match self.step.clone() {
Step::Option => self.choose(&token),
Step::List => CmdResult::Dispatch(format!("XREFLIST {value}")),
Step::Names(op) => CmdResult::Dispatch(format!("XREF {op} {value}")),
Step::File { overlay } => {
let verb = if overlay { "XREFOVERLAYFILE" } else { "XREFATTACHFILE" };
CmdResult::Dispatch(format!("{verb} {value}"))
}
Step::PathNames => {
let Some((name, old)) = self
.saved_paths
.iter()
.find(|(name, _)| crate::io::xref_model::wildcard_match(name, &value))
.cloned()
else {
return Some(CmdResult::CancelWithMessage(
"No matching xref names found.".to_string(),
));
};
self.step = Step::NewPath { name: name.clone() };
CmdResult::ReportMeasurement(format!(
"{}\n{}",
format!("xref name: \"{}\"", name),
format!("Old path: {}", display_path(&old))
))
}
Step::NewPath { name } => CmdResult::Dispatch(format!("XREF Path {name} {value}")),
Step::PathTypeNames => {
self.step = Step::PathTypeKind { names: value };
CmdResult::NeedPoint
}
Step::PathTypeKind { names } => {
let kind = match token.as_str() {
"F" | "FULL" => "Full",
"R" | "RELATIVE" => "Relative",
"N" | "NONE" => "None",
_ => {
return Some(CmdResult::ReportError(
"Invalid option keyword.".to_string(),
))
}
};
CmdResult::Dispatch(format!("XREF Pathtype {kind} {names}"))
}
Step::ShowNames => CmdResult::Dispatch("XREFSHOW".to_string()),
})
}
fn on_enter(&mut self) -> CmdResult {
match self.step.clone() {
Step::Option => self.choose("A"),
Step::List => CmdResult::Dispatch("XREFLIST *".to_string()),
Step::NewPath { .. } => {
CmdResult::CancelWithMessage("Path unchanged.".to_string())
}
Step::PathTypeKind { .. } => CmdResult::NeedPoint,
_ => CmdResult::Cancel,
}
}
}
/// A stored path shown the way the platform writes it.
pub fn display_path(path: &str) -> String {
if cfg!(windows) {
path.replace('/', "\\")
} else {
path.to_string()
}
}
#[cfg(test)]
mod tests {
use super::XrefCommand;
use crate::command::{CadCommand, CmdResult};
#[test]
fn enter_chooses_attach() {
let mut cmd = XrefCommand::new(Vec::new());
assert!(matches!(cmd.on_enter(), CmdResult::NeedPoint));
assert!(cmd.prompt().contains("Enter name of file to attach:"));
}
#[test]
fn list_defaults_to_every_reference() {
let mut cmd = XrefCommand::new(Vec::new());
cmd.on_text_input("?");
assert!(cmd.prompt().contains("Enter xref name(s) to list <*>:"));
assert!(matches!(cmd.on_enter(), CmdResult::Dispatch(ref s) if s == "XREFLIST *"));
}
#[test]
fn overlay_file_is_dispatched() {
let mut cmd = XrefCommand::new(Vec::new());
cmd.on_text_input("O");
assert!(cmd.prompt().contains("Enter name of file to overlay:"));
assert!(matches!(
cmd.on_text_input("C:/refs/site plan.dwg"),
Some(CmdResult::Dispatch(ref s)) if s == "XREFOVERLAYFILE C:/refs/site plan.dwg"
));
}
}