feat(xref): XREF command options and guided XATTACH

XREF command with status, reload, unload, detach, path, pathtype, bind, overlay, and attach options, plus guided XATTACH with unique naming and placement prompts.
This commit is contained in:
Karim Jerbi committed 2026-09-15 23:56:31 +01:00
1 parent f56d8a948e
commit 37e2b50e1c
3 files changed
+590 -78

No files matched your search

+25
View File
@@ -2318,9 +2318,34 @@ impl OpenCADStudio {
}
};
if let Some(path) = xattach_path {
// XREF-Task6: the host drawing path lives in the tab
// (`current_path`), not in `Scene`, so the self-attach
// guard runs here via the pure `is_self_attach` helper.
let host_file = self.tabs[i].current_path.clone();
let host_base = host_file
.as_deref()
.and_then(|p| p.parent())
.map(|p| p.to_path_buf());
if let Some(host) = host_file.as_deref() {
if crate::modules::insert::xattach::is_self_attach(
host,
&path,
host_base.as_deref(),
) {
self.command_line.push_error(
"XATTACH: cannot attach the host drawing into itself.",
);
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
return Task::none();
}
}
crate::modules::insert::xattach::prepare_xref_block(
&mut self.tabs[i].scene,
&path,
host_base.as_deref(),
);
// Resolving the xref merged its layer / linetype tables
// into the document — mirror them into the Layers panel
+195 -55
View File
@@ -1,6 +1,45 @@
use super::*;
/// Placeholder for XREF reference operations landing in Tasks 8/9.
/// Each arm formats it with its own op name via `NOT_YET.replace("<op>", …)`.
const NOT_YET: &str = "XREF: '<op>' ships with reference operations (Task 8/9).";
impl OpenCADStudio {
/// Shared per-reference status reporting for XRELOAD and XREF Reload.
/// Extracted verbatim from the XRELOAD arm — no behavior change.
pub(in crate::app) fn report_xref_status(&mut self, info: &crate::io::xref::XrefInfo) {
match info.status {
crate::io::xref::XrefStatus::Loaded => {
self.command_line
.push_output(crate::tf!("XREF Reloaded \"{}\"", info.name).as_ref());
}
crate::io::xref::XrefStatus::Recovered => {
self.command_line.push_error(crate::tf!(
"XREF Reloaded with repairs: \"{}\"",
info.name
).as_ref());
}
crate::io::xref::XrefStatus::NotFound => {
self.command_line.push_error(crate::tf!(
"XREF Not found: \"{}\" ({})",
info.name, info.path
).as_ref());
}
crate::io::xref::XrefStatus::Failed => {
self.command_line.push_error(crate::tf!(
"XREF Reload failed: \"{}\" ({})",
info.name, info.path
).as_ref());
}
crate::io::xref::XrefStatus::Unloaded => {
self.command_line.push_info(crate::tf!(
"XREF Unloaded (skipped): \"{}\"",
info.name
).as_ref());
}
}
}
pub(in crate::app) fn copy_entities_to_clipboard(
&mut self,
i: usize,
@@ -539,34 +578,148 @@ impl OpenCADStudio {
}
}
"XREF" => {
// 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() {
cmd if cmd.eq_ignore_ascii_case("XREF") || cmd.to_ascii_uppercase().starts_with("XREF ") => {
// XREF sub-option dispatcher (Task 5). The verb is
// case-insensitive; arguments split on whitespace.
let rest = cmd
.splitn(2, char::is_whitespace)
.nth(1)
.unwrap_or("")
.trim();
let mut parts = rest.split_whitespace();
let op = parts.next().unwrap_or("");
if op.is_empty() || op == "?" {
// List every reference via collect_entries. The base dir
// is the drawing's parent (or "." unsaved); nothing is
// treated as unloaded here. Header lines stay
// byte-identical to the legacy listing; each entry line
// gains an " [Attach]" / " [Overlay]" type suffix.
let base_dir: std::path::PathBuf = self.tabs[i]
.current_path
.as_ref()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| std::path::PathBuf::from("."));
let entries = crate::io::xref::collect_entries(
&self.tabs[i].scene.document,
&base_dir,
&std::collections::HashSet::<u64>::new(),
);
if entries.is_empty() {
self.command_line
.push_output(crate::t!("XREF No external references in this drawing.").as_ref());
} else {
self.command_line.push_output(crate::t!("XREF External references:").as_ref());
for entry in &entries {
let path = if entry.saved_path.is_empty() {
"(no path)".to_string()
} else {
br.xref_path.clone()
}
)
})
.collect();
if xrefs.is_empty() {
self.command_line
.push_output(crate::t!("XREF No external references in this drawing.").as_ref());
} else {
self.command_line.push_output(crate::t!("XREF External references:").as_ref());
for line in xrefs {
self.command_line.push_output(&line);
entry.saved_path.clone()
};
let suffix =
match entry.ref_type {
crate::io::xref_model::RefType::Overlay => " [Overlay]",
crate::io::xref_model::RefType::Attach => " [Attach]",
};
self.command_line
.push_output(&format!(" {} — {}{}", entry.name, path, suffix));
}
}
} else if op.eq_ignore_ascii_case("Reload") {
// Reload all (no pattern) or the subset matching
// <pattern> (case-insensitive, `*`/`?` wildcards).
let pattern = parts.next().unwrap_or("").trim();
// A concrete pattern that matches no entry resolves
// nothing: pre-check the entry list (same base_dir +
// empty unloaded set as the list path) and return early
// without touching resolve_xrefs — no mutation, no
// populate/refresh. Bare Reload and `*` keep the
// resolve-everything path below.
if !pattern.is_empty() && pattern != "*" {
let base_dir: std::path::PathBuf = self.tabs[i]
.current_path
.as_ref()
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
.unwrap_or_else(|| std::path::PathBuf::from("."));
let entries = crate::io::xref::collect_entries(
&self.tabs[i].scene.document,
&base_dir,
&std::collections::HashSet::<u64>::new(),
);
let any = entries
.iter()
.any(|e| crate::io::xref_model::wildcard_match(&e.name, pattern));
if !any {
self.command_line.push_error(crate::tf!(
"XREF: no references match '{}'.",
pattern
).as_ref());
return Some(self.finish_dispatch(cmd));
}
}
if let Some(path) = &self.tabs[i].current_path.clone() {
if let Some(base_dir) = path.parent() {
let (infos, _dropped) = crate::io::xref::resolve_xrefs(
&mut self.tabs[i].scene.document,
base_dir,
);
let matched: Vec<&crate::io::xref::XrefInfo> = if pattern.is_empty() {
infos.iter().collect()
} else {
infos
.iter()
.filter(|n| {
crate::io::xref_model::wildcard_match(&n.name, pattern)
})
.collect()
};
if matched.is_empty() {
self.command_line.push_error(crate::tf!(
"XREF: no references match '{}'.",
pattern
).as_ref());
} else {
for info in matched {
self.report_xref_status(info);
}
}
self.tabs[i].scene.populate_hatches_from_document();
self.tabs[i].scene.populate_images_from_document();
self.tabs[i].scene.populate_meshes_from_document();
// The reload may have merged new xref layers /
// linetypes — mirror them into the Layers panel and
// ribbon dropdowns (#407).
self.refresh_layer_panel();
}
} else {
self.command_line
.push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref());
}
} else if op.eq_ignore_ascii_case("Unload") {
self.command_line
.push_output(&NOT_YET.replace("<op>", "Unload"));
} else if op.eq_ignore_ascii_case("Detach") {
self.command_line
.push_output(&NOT_YET.replace("<op>", "Detach"));
} else if op.eq_ignore_ascii_case("Path") {
self.command_line
.push_output(&NOT_YET.replace("<op>", "Path"));
} else if op.eq_ignore_ascii_case("Pathtype") {
self.command_line
.push_output(&NOT_YET.replace("<op>", "Pathtype"));
} else if op.eq_ignore_ascii_case("Bind") {
self.command_line
.push_output(&NOT_YET.replace("<op>", "Bind"));
} else if op.eq_ignore_ascii_case("Overlay") {
self.command_line
.push_output(&NOT_YET.replace("<op>", "Overlay"));
} else if op.eq_ignore_ascii_case("Attach") {
self.command_line
.push_output(&NOT_YET.replace("<op>", "Attach"));
} else {
self.command_line.push_error(crate::tf!(
"XREF: unknown option '{}'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach",
op
).as_ref());
}
}
@@ -579,36 +732,7 @@ impl OpenCADStudio {
base_dir,
);
for info in &infos {
match info.status {
crate::io::xref::XrefStatus::Loaded => {
self.command_line
.push_output(crate::tf!("XREF Reloaded \"{}\"", info.name).as_ref());
}
crate::io::xref::XrefStatus::Recovered => {
self.command_line.push_error(crate::tf!(
"XREF Reloaded with repairs: \"{}\"",
info.name
).as_ref());
}
crate::io::xref::XrefStatus::NotFound => {
self.command_line.push_error(crate::tf!(
"XREF Not found: \"{}\" ({})",
info.name, info.path
).as_ref());
}
crate::io::xref::XrefStatus::Failed => {
self.command_line.push_error(crate::tf!(
"XREF Reload failed: \"{}\" ({})",
info.name, info.path
).as_ref());
}
crate::io::xref::XrefStatus::Unloaded => {
self.command_line.push_info(crate::tf!(
"XREF Unloaded (skipped): \"{}\"",
info.name
).as_ref());
}
}
self.report_xref_status(info);
}
self.tabs[i].scene.populate_hatches_from_document();
self.tabs[i].scene.populate_images_from_document();
@@ -784,6 +908,22 @@ mod tests {
assert!(app.block_palette.blocks.iter().any(|b| b.name == "Widget"));
}
#[test]
fn xref_question_mark_lists_count() {
let mut app = fresh_app();
let out = run_capture(&mut app, "XREF ?");
assert!(out.contains("No external references") || out.contains("External references"));
}
#[test]
fn xref_reload_no_match_resolves_nothing() {
let mut app = fresh_app();
let dirty_before = app.tabs[0].dirty;
let out = run_capture(&mut app, "XREF Reload ZZZ_NO_SUCH_REF");
assert!(out.contains("no references match"));
assert_eq!(app.tabs[0].dirty, dirty_before);
}
#[test]
fn blockpalette_refreshes_when_switching_to_same_named_block_in_another_tab() {
let mut app = fresh_app();
+370 -23
View File
@@ -2,11 +2,11 @@
// 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.
// 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.
// then an INSERT entity is committed with the chosen scale/rotation.
use acadrust::entities::{Block, BlockEnd, Insert};
use acadrust::tables::block_record::{BlockFlags, BlockRecord};
@@ -16,7 +16,8 @@ use glam::DVec3;
use crate::t;
use crate::command::{CadCommand, CmdResult, WorkingPlane};
use crate::command::{CadCommand, CmdResult, InputKind, WorkingPlane};
use crate::io::xref_model::normalize_lexical;
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
use crate::scene::Scene;
@@ -30,10 +31,20 @@ 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 },
}
pub struct XAttachCommand {
path: String,
block_name: String,
plane: WorkingPlane,
step: XAttachStep,
}
impl XAttachCommand {
@@ -44,8 +55,21 @@ impl XAttachCommand {
path,
block_name,
plane: WorkingPlane::default(),
step: XAttachStep::Point,
}
}
fn commit_insert(&self, point: DVec3, sx: f64, sy: f64, sz: f64, rotation: f64) -> EntityType {
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;
self.plane.place_entity(EntityType::Insert(ins))
}
}
impl CadCommand for XAttachCommand {
@@ -58,28 +82,102 @@ impl CadCommand for XAttachCommand {
}
fn prompt(&self) -> String {
t!(
"XATTACH Specify insertion point for \"%{name}\":",
name = self.block_name
)
.into_owned()
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()
}
XAttachStep::Rotate { .. } => {
t!("XATTACH Specify rotation angle <0>:").into_owned()
}
}
}
fn input_kind(&self) -> InputKind {
match self.step {
XAttachStep::Point => InputKind::Point,
XAttachStep::Scale { .. } | XAttachStep::Rotate { .. } => InputKind::SingleToken,
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
// We return the INSERT entity; the command handler in commands.rs
// calls `prepare_xref_block` on the scene before committing.
let point = self.plane.to_local(pt);
CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Insert(Insert::new(
self.block_name.clone(),
Vector3::new(point.x, point.y, point.z),
))))
match self.step {
XAttachStep::Point => {
let point = self.plane.to_local(pt);
self.step = XAttachStep::Scale { point };
CmdResult::NeedPoint
}
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))
}
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
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 };
}
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),
))
} else {
Some(CmdResult::NeedPoint)
}
}
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
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,
};
CmdResult::NeedPoint
}
XAttachStep::Rotate { point, sx, sy, sz } => {
CmdResult::CommitAndExit(self.commit_insert(point, sx, sy, sz, 0.0))
}
}
}
fn on_preview_wires(&mut self, _pt: DVec3) -> Vec<WireModel> {
// XREF-Task6: PScale drag preview — no framework hook; revisit
vec![]
}
@@ -96,19 +194,128 @@ pub fn path_to_block_name(path: &str) -> String {
.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.)
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()) {
return stem.to_string();
}
let mut n = 1u32;
loop {
let candidate = format!("{stem}_{n}");
if !upper_taken
.iter()
.any(|t| *t == candidate.to_uppercase())
{
return candidate;
}
n += 1;
}
}
/// 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.
pub fn resolve_xref_store_path(path: &str, host_base_dir: Option<&std::path::Path>) -> String {
let p = std::path::Path::new(path);
if p.is_absolute() {
return path.replace('\\', "/");
}
match host_base_dir {
Some(base) => base.join(p).to_string_lossy().replace('\\', "/"),
None => path.to_string(),
}
}
// 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
/// host file's parent) before comparing.
pub fn is_self_attach(
host_file: &std::path::Path,
ref_path: &str,
host_base_dir: Option<&std::path::Path>,
) -> bool {
let joined = resolve_xref_store_path(ref_path, host_base_dir);
let candidate = if std::path::Path::new(&joined).is_absolute() {
joined
} else if let Some(parent) = host_file.parent() {
parent
.join(&joined)
.to_string_lossy()
.replace('\\', "/")
} else {
joined
};
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,
}
}
/// 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);
///
/// 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(
scene: &mut Scene,
path: &str,
host_base_dir: Option<&std::path::Path>,
) -> String {
let stem = path_to_block_name(path);
let taken: Vec<String> = scene
.document
.block_records
.iter()
.map(|b| b.name.clone())
.collect();
let block_name = unique_block_name(&stem, &taken);
// 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();
@@ -119,21 +326,161 @@ pub fn prepare_xref_block(scene: &mut Scene, path: &str) -> String {
has_attributes: false,
is_external: false,
};
br.xref_path = path.to_string();
br.xref_path = store_path.clone();
let _ = scene.document.block_records.add(br);
// Create BLOCK entity.
let b = Block::new(&block_name, Vector3::zero()).with_xref_path(path);
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()));
// Resolve the XREF content immediately.
let path_buf = std::path::PathBuf::from(path);
let path_buf = std::path::PathBuf::from(&store_path);
if let Some(base_dir) = path_buf.parent() {
let _ = crate::io::xref::resolve_xrefs(&mut scene.document, base_dir);
}
block_name
}
#[cfg(test)]
mod tests {
use super::{
is_self_attach, parse_scale_spec, path_to_block_name, resolve_xref_store_path,
unique_block_name, XAttachCommand,
};
use crate::command::{CadCommand, CmdResult};
use acadrust::EntityType;
use glam::DVec3;
#[test]
fn block_name_collision_gets_suffix() {
assert_eq!(unique_block_name("PLAN", &["PLAN"]), "PLAN_1");
assert_eq!(unique_block_name("PLAN", &["PLAN", "PLAN_1"]), "PLAN_2");
}
#[test]
fn unique_block_name_is_case_insensitive() {
assert_eq!(unique_block_name("PLAN", &["plan"]), "PLAN_1");
assert_eq!(unique_block_name("plan", &["PLAN", "plan_1"]), "plan_2");
assert_eq!(unique_block_name("NEW", &["PLAN"]), "NEW");
}
#[test]
fn block_name_derives_from_stem() {
assert_eq!(path_to_block_name("C:/refs/plan.dwg"), "PLAN");
}
#[test]
fn relative_ref_resolves_against_host_base() {
let base = std::path::Path::new("C:/Drawings");
assert_eq!(
resolve_xref_store_path("refs/plan.dwg", Some(base)),
"C:/Drawings/refs/plan.dwg"
);
assert_eq!(
resolve_xref_store_path("C:/Lib/plan.dwg", Some(base)),
"C:/Lib/plan.dwg"
);
assert_eq!(resolve_xref_store_path("refs/plan.dwg", None), "refs/plan.dwg");
}
#[test]
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);
}
#[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);
}
_ => panic!("expected CommitAndExit(Insert)"),
}
}
#[test]
fn placement_flow_triplet_and_typed_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)"),
}
}
#[test]
fn placement_flow_corner_pick_sets_xy_scale() {
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)"),
}
}
}