forked from 1b-agent/opencadstudio-upstream
Xref attach: placement preview, relative paths on the first save, no-match messages
The reference follows the cursor while its insertion point, scale or rotation is asked (preview-only PScale/PX/PY/PZ/PRotate included); a relative path chosen before the drawing had a file becomes relative on the first save, and relative paths start from "./" at or below the drawing's folder; -XREF name options report "No matching xref names found.". Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6bd55bc32e
commit
498a97df0f
8 files changed
+204
-7
No files matched your search
@@ -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<crate::scene::model::wire_model::WireModel>, 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<acadrust::EntityType> = 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;
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<String>,
|
||||
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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<WireModel>,
|
||||
/// 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<WireModel>, 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<EntityType> {
|
||||
@@ -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<WireModel> {
|
||||
vec![]
|
||||
fn on_preview_wires(&mut self, pt: DVec3) -> Vec<WireModel> {
|
||||
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<XrefAttachRequest> {
|
||||
|
||||
@@ -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<CmdResult> {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user