diff --git a/src/app/commands/xref_attach.rs b/src/app/commands/xref_attach.rs index a28dc2fcc..bcec577b3 100644 --- a/src/app/commands/xref_attach.rs +++ b/src/app/commands/xref_attach.rs @@ -125,7 +125,8 @@ impl OpenCADStudio { return; } } - if let Some(existing) = existing_reference(&self.tabs[i].scene, &name) { + let existing = existing_reference(&self.tabs[i].scene, &name); + if let Some(existing) = existing.as_ref() { self.command_line .push_output(&format!("Xref \"{}\" has already been defined.", existing)); self.command_line @@ -155,10 +156,60 @@ impl OpenCADStudio { self.commit_xref_attach(i, request, entity); return; } + let (wires, source_units) = self.xref_preview(i, existing.as_deref(), &full); + let host_units = self.tabs[i].scene.document.header.insertion_units; + let unit = crate::app::properties::insert_unit_scale(host_units, source_units).unwrap_or(1.0); + let command = command.with_preview(wires, unit); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } + /// The reference's geometry for the placement ghost, in its own units: + /// an existing definition's content, or the file's model space. + fn xref_preview( + &self, + i: usize, + existing: Option<&str>, + path: &str, + ) -> (Vec, i16) { + // ponytail: a huge reference skips the ghost; cap by entity count. + const MAX_PREVIEW_ENTITIES: usize = 20_000; + let scene = &self.tabs[i].scene; + if let Some(name) = existing { + let units = scene + .document + .block_records + .get(name) + .map(|br| br.units) + .unwrap_or(0); + return (scene.block_preview_wires(name), units); + } + #[cfg(not(target_arch = "wasm32"))] + if let Ok(source) = crate::io::load_file(std::path::Path::new(path)) { + let owner = source.header.model_space_block_handle; + let entities: Vec = source + .entities() + .filter(|e| e.common().owner_handle == owner) + .filter(|e| { + !matches!( + e, + acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_) + ) + }) + .take(MAX_PREVIEW_ENTITIES + 1) + .cloned() + .collect(); + if entities.len() <= MAX_PREVIEW_ENTITIES { + return ( + scene.wires_for_entities(&entities), + source.header.insertion_units, + ); + } + } + let _ = path; + (Vec::new(), 0) + } + /// Creates (or reuses) the definition and commits its INSERT — one undo /// step for both, so undo leaves no definition behind. pub(in crate::app) fn commit_xref_attach( @@ -170,6 +221,9 @@ impl OpenCADStudio { let pending = self.begin_undo(i, "XATTACH", 1, false); let host = self.tabs[i].current_path.clone(); let name = prepare_xref_definition(&mut self.tabs[i].scene, &request, host.as_deref()); + if host.is_none() && request.path_type == Pathtype::Relative { + self.tabs[i].xref_relative_on_save.insert(name.clone()); + } if let acadrust::EntityType::Insert(insert) = &mut entity { insert.block_name = name; } diff --git a/src/app/document.rs b/src/app/document.rs index be6004a79..aed22517d 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -231,6 +231,9 @@ pub(super) struct DocumentTab { /// Session set of unloaded reference keys (Task 8b). Owns the set that /// `collect_entries` takes as `unloaded`, so CLI and palette agree. pub(super) xref_unloaded: crate::io::xref_model::UnloadSet, + /// References attached with a relative path while the drawing had no + /// file yet: stored full until the first save, then made relative. + pub(super) xref_relative_on_save: rustc_hash::FxHashSet, /// Load-time mtimes per reference key (Task 8b). Written on every /// palette refresh; `Stale` is detectable from the second refresh on. pub(super) xref_stat_cache: crate::io::xref_model::RefStatCache, @@ -650,6 +653,7 @@ impl DocumentTab { active_mleader_style: "Standard".to_string(), last_synced_camera_gen: 0, xref_unloaded: crate::io::xref_model::UnloadSet::default(), + xref_relative_on_save: rustc_hash::FxHashSet::default(), xref_stat_cache: crate::io::xref_model::RefStatCache::default(), xref_missing: 0, is_start: false, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index d32f5a425..6a5e38202 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2304,6 +2304,15 @@ impl OpenCADStudio { }); let clone_started = iced::time::Instant::now(); let mut snapshot = self.tabs[i].scene.document_for_save(); + // First save: references attached with a relative path type while + // the drawing had no file become relative to it. + if self.tabs[i].current_path.is_none() && purpose != crate::app::SavePurpose::Autosave { + crate::io::xref::make_relative( + &mut snapshot, + &self.tabs[i].xref_relative_on_save, + &path, + ); + } // Save-As across folders: rebase relative reference paths onto the // new base dir inside the snapshot only (live strings are untouched). if purpose == crate::app::SavePurpose::SaveAs { @@ -2606,6 +2615,14 @@ impl OpenCADStudio { } if outcome.set_current_path { let old_path = self.tabs[i].current_path.clone(); + if old_path.is_none() { + let pending = std::mem::take(&mut self.tabs[i].xref_relative_on_save); + crate::io::xref::make_relative( + &mut self.tabs[i].scene.document, + &pending, + &outcome.path, + ); + } self.tabs[i].current_path = Some(outcome.path.clone()); self.tabs[i].scene.document.version = outcome.version; if outcome.purpose == crate::app::SavePurpose::SaveAs { diff --git a/src/io/xref.rs b/src/io/xref.rs index 4ddf257ee..9c3c20ed6 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -1864,6 +1864,21 @@ fn same_file(a: &Path, b: &Path) -> bool { /// Shared by Detach (which then also erases the BlockRecord itself) and BIND /// (which drops the previous merge before re-merging under `$N$` names). The /// xref BlockRecord itself, its INSERTs, and non-prefixed symbols stay. +/// Make the named references' paths relative to `host` (their drawing's +/// first save); a reference that cannot be relative keeps its full path. +pub fn make_relative( + doc: &mut CadDocument, + names: &rustc_hash::FxHashSet, + host: &Path, +) { + for name in names { + let Some(key) = doc.block_records.get(name).map(|br| br.handle.value()) else { + continue; + }; + let _ = apply_pathtype(doc, key, crate::io::xref_model::Pathtype::Relative, host); + } +} + /// Drop from a save copy what resolving each xref merged into the host but /// the file never stores: the reference's own geometry, its nested block /// definitions and its text and dimension styles. Dependent layers and diff --git a/src/io/xref_model.rs b/src/io/xref_model.rs index 063f40d25..b25952d2a 100644 --- a/src/io/xref_model.rs +++ b/src/io/xref_model.rs @@ -247,6 +247,11 @@ pub fn to_pathtype_result( if parts.is_empty() { return Ok(file_name_only(path)); } + // A path at or below the host folder starts "./" ("./plan.dwg", + // "./refs/plan.dwg"), as the reference writes it; one above starts "../". + if parts[0] != ".." { + parts.insert(0, ".".to_string()); + } Ok(parts.join("/")) } } @@ -846,7 +851,7 @@ mod tests { let full = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::Full); assert_eq!(full, "C:/Drawings/refs/plan.dwg"); let rel = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::Relative); - assert_eq!(rel, "refs/plan.dwg"); + assert_eq!(rel, "./refs/plan.dwg"); let none = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::None); assert_eq!(none, "plan.dwg"); } diff --git a/src/modules/insert/xattach.rs b/src/modules/insert/xattach.rs index cf42ea40b..c9d2f7967 100644 --- a/src/modules/insert/xattach.rs +++ b/src/modules/insert/xattach.rs @@ -136,6 +136,13 @@ pub struct XAttachCommand { /// Degrees. rotation: f64, rotation_fixed: bool, + /// Ghost of the reference's model space in its own coordinates. + preview: Vec, + /// Drawing units per reference unit; the INSERT's scale carries it. + unit: f64, + /// PScale / PX / PY / PZ / PRotate: preview-only values. + preview_scale: [f64; 3], + preview_rotation: f64, } const NONZERO: &str = "Value must be nonzero."; @@ -154,6 +161,10 @@ impl XAttachCommand { scale_fixed: placement.scale.is_some(), rotation: placement.rotation.unwrap_or(0.0), rotation_fixed: placement.rotation.is_some(), + preview: Vec::new(), + unit: 1.0, + preview_scale: [1.0; 3], + preview_rotation: 0.0, }; if command.point.is_some() { command.step = command.next_step().unwrap_or(Step::Rotation); @@ -175,6 +186,44 @@ impl XAttachCommand { ) } + /// Show `wires` (the reference's model space) while placing it. + pub fn with_preview(mut self, wires: Vec, unit: f64) -> Self { + self.preview = wires; + self.unit = if unit.is_finite() && unit != 0.0 { unit } else { 1.0 }; + self + } + + /// Placement shown for the cursor at `cursor` (local): insertion point, + /// scale and rotation (degrees) as far as they are known at this step. + fn preview_placement(&self, cursor: DVec3) -> (DVec3, [f64; 3], f64) { + let rotation = if self.rotation_fixed { + self.rotation + } else { + self.preview_rotation + }; + let base = self.point.unwrap_or(cursor); + match self.step { + Step::Insert | Step::Preset(_) => { + let scale = if self.scale_fixed { self.scale } else { self.preview_scale }; + (cursor, scale, rotation) + } + Step::XScale | Step::Corner | Step::XyzX => { + let (dx, dy) = (cursor.x - base.x, cursor.y - base.y); + let scale = if dx.abs() > 1e-12 && dy.abs() > 1e-12 { + [dx, dy, dx.abs()] + } else { + [1.0; 3] + }; + (base, scale, rotation) + } + Step::Rotation => { + let angle = (cursor.y - base.y).atan2(cursor.x - base.x).to_degrees(); + (base, self.scale, angle) + } + _ => (base, self.scale, rotation), + } + } + /// The finished INSERT when nothing is left to ask (all values fixed in /// the dialog). pub fn immediate(&self) -> Option { @@ -357,7 +406,11 @@ impl CadCommand for XAttachCommand { } // 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 => {} + Preset::PScale => self.preview_scale = [value; 3], + Preset::PX => self.preview_scale[0] = value, + Preset::PY => self.preview_scale[1] = value, + Preset::PZ => self.preview_scale[2] = value, + Preset::PRotate => self.preview_rotation = value, } self.step = Step::Insert; Some(CmdResult::NeedPoint) @@ -451,8 +504,19 @@ impl CadCommand for XAttachCommand { } } - fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { - vec![] + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + if self.preview.is_empty() { + return Vec::new(); + } + let (origin, scale, degrees) = self.preview_placement(self.plane.to_local(pt)); + let (sin, cos) = degrees.to_radians().sin_cos(); + let unit = self.unit; + let plane = self.plane; + let place = |p: DVec3| { + let (x, y, z) = (p.x * scale[0] * unit, p.y * scale[1] * unit, p.z * scale[2] * unit); + plane.to_world(origin + DVec3::new(x * cos - y * sin, x * sin + y * cos, z)) + }; + self.preview.iter().map(|wire| wire.mapped(place)).collect() } fn xattach_request(&self) -> Option { diff --git a/src/modules/insert/xref_cmd.rs b/src/modules/insert/xref_cmd.rs index 10f172650..96a698c2a 100644 --- a/src/modules/insert/xref_cmd.rs +++ b/src/modules/insert/xref_cmd.rs @@ -36,6 +36,13 @@ impl XrefCommand { } } + /// Whether any reference name matches `pattern` (`*` / `?` wildcards). + fn matches(&self, pattern: &str) -> bool { + self.saved_paths + .iter() + .any(|(name, _)| crate::io::xref_model::wildcard_match(name, pattern)) + } + fn choose(&mut self, token: &str) -> CmdResult { self.step = match token { "?" => Step::List, @@ -118,6 +125,15 @@ impl CadCommand for XrefCommand { fn on_text_input(&mut self, text: &str) -> Option { let value = text.trim().trim_matches('"').to_string(); let token = value.to_uppercase(); + let names_step = matches!( + self.step, + Step::Names(_) | Step::PathTypeNames | Step::ShowNames + ); + if names_step && !self.matches(&value) { + return Some(CmdResult::Measurement( + "No matching xref names found.".to_string(), + )); + } Some(match self.step.clone() { Step::Option => self.choose(&token), Step::List => CmdResult::Dispatch(format!("XREFLIST {value}")), @@ -133,7 +149,7 @@ impl CadCommand for XrefCommand { .find(|(name, _)| crate::io::xref_model::wildcard_match(name, &value)) .cloned() else { - return Some(CmdResult::CancelWithMessage( + return Some(CmdResult::Measurement( "No matching xref names found.".to_string(), )); }; @@ -171,7 +187,7 @@ impl CadCommand for XrefCommand { Step::Option => self.choose("A"), Step::List => CmdResult::Dispatch("XREFLIST *".to_string()), Step::NewPath { .. } => { - CmdResult::CancelWithMessage("Path unchanged.".to_string()) + CmdResult::Measurement("Path unchanged.".to_string()) } Step::PathTypeKind { .. } => CmdResult::NeedPoint, _ => CmdResult::Cancel, diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index d1fc50bb8..5007a8a06 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -633,6 +633,28 @@ impl WireModel { out } + /// Return a preview clone with every point mapped through `map` (an + /// INSERT-style placement: per-axis scale, rotation, translation). + /// Tangent geometry is dropped; a preview is not snapped to. + pub fn mapped(&self, map: impl Fn(glam::DVec3) -> glam::DVec3) -> Self { + let mut out = self.clone(); + out.name = format!("preview_{}", self.name); + out.color = Self::CYAN; + out.selected = false; + map_points(&mut out.points, &mut out.points_low, &map); + if let Some(marker) = &mut out.point_marker { + marker.origin = map(marker.origin); + } + if !out.text_verts.is_empty() { + out.text_verts = map_text_verts(&self.text_verts, |x, y, z| { + let p = map(glam::DVec3::new(x, y, z)); + (p.x, p.y, p.z) + }); + } + out.tangent_geoms.clear(); + out + } + /// Return a clone with every point rotated around `center` by `angle_rad`. pub fn rotated(&self, center: glam::Vec3, angle_rad: f32) -> Self { self.rotated_about_axis(center, glam::Vec3::Z, angle_rad)