diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index fd118b42a..3e15e362e 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -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 diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index a0f5d2583..99305b8df 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -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("", …)`. +const NOT_YET: &str = "XREF: '' 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 = 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::::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 + // (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::::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("", "Unload")); + } else if op.eq_ignore_ascii_case("Detach") { + self.command_line + .push_output(&NOT_YET.replace("", "Detach")); + } else if op.eq_ignore_ascii_case("Path") { + self.command_line + .push_output(&NOT_YET.replace("", "Path")); + } else if op.eq_ignore_ascii_case("Pathtype") { + self.command_line + .push_output(&NOT_YET.replace("", "Pathtype")); + } else if op.eq_ignore_ascii_case("Bind") { + self.command_line + .push_output(&NOT_YET.replace("", "Bind")); + } else if op.eq_ignore_ascii_case("Overlay") { + self.command_line + .push_output(&NOT_YET.replace("", "Overlay")); + } else if op.eq_ignore_ascii_case("Attach") { + self.command_line + .push_output(&NOT_YET.replace("", "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(); diff --git a/src/modules/insert/xattach.rs b/src/modules/insert/xattach.rs index c8c45d5cf..3fb937427 100644 --- a/src/modules/insert/xattach.rs +++ b/src/modules/insert/xattach.rs @@ -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 { + 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 { + // 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]) -> String { + let upper_taken: Vec = 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::() + .ok() + .filter(|v| *v != 0.0) + .map(|v| (v, v, 1.0)), + [x, y, z] => { + let parsed: Option> = + [x, y, z].iter().map(|s| s.parse::().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 = 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)"), + } + } +}