Merge main into codex/graphic-attributes-cleanup

Resolve conflicts from main's rustfmt pass by taking main's versions of
command.rs, style.rs, view/mod.rs, scene/mod.rs, ui/window/mod.rs and
ui/properties.rs and re-applying this branch's Graphic Attributes changes.
Add the Graphic Attributes strings to the new lo-LA catalog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01E1QnME6WJbD5iGFae1eeCb
This commit is contained in:
Claude committed 2026-10-11 19:09:37 +00:00
commit 44f8f76ab3
596 files changed
+69069 -34120

No files matched your search

+92 -27
View File
@@ -56,7 +56,10 @@ impl OpenCADStudio {
async {
let handle = rfd::AsyncFileDialog::new()
.set_title(crate::t!("Select Drawing to Insert as Block").as_ref())
.add_filter(crate::t!("DWG/DXF Files").as_ref(), &["dwg", "dxf", "DWG", "DXF"])
.add_filter(
crate::t!("DWG/DXF Files").as_ref(),
&["dwg", "dxf", "DWG", "DXF"],
)
.pick_file()
.await;
match handle {
@@ -67,7 +70,9 @@ impl OpenCADStudio {
|r| Message::BlockPalette(BlockPaletteMsg::FilePicked(r)),
);
}
BlockPaletteMsg::FilePicked(Ok(path)) => return self.palette_insert(Item::File(path), None),
BlockPaletteMsg::FilePicked(Ok(path)) => {
return self.palette_insert(Item::File(path), None)
}
BlockPaletteMsg::FilePicked(Err(e)) | BlockPaletteMsg::LibraryPicked(Err(e)) => {
if e != "Cancelled" {
self.command_line.push_error(&e);
@@ -99,7 +104,12 @@ impl OpenCADStudio {
BlockPaletteMsg::LibraryPicked(Ok(path)) => self.open_block_library(path),
BlockPaletteMsg::LibrarySelect(path) => self.open_block_library(PathBuf::from(path)),
BlockPaletteMsg::LibraryUp => {
if let Some(parent) = self.block_palette.library_dir.as_ref().and_then(|d| d.parent()) {
if let Some(parent) = self
.block_palette
.library_dir
.as_ref()
.and_then(|d| d.parent())
{
let parent = parent.to_path_buf();
self.list_block_library(parent);
}
@@ -165,17 +175,30 @@ impl OpenCADStudio {
return None;
}
if path.is_dir() {
Some(LibraryEntry { path, folder: true, thumb: None })
Some(LibraryEntry {
path,
folder: true,
thumb: None,
})
} else if is_drawing(&path) {
let thumb = crate::io::thumbnail::read_handle(&path);
Some(LibraryEntry { path, folder: false, thumb })
Some(LibraryEntry {
path,
folder: false,
thumb,
})
} else {
None
}
})
.collect();
entries.sort_by_key(|e| {
(!e.folder, e.path.file_name().map(|n| n.to_string_lossy().to_lowercase()))
(
!e.folder,
e.path
.file_name()
.map(|n| n.to_string_lossy().to_lowercase()),
)
});
self.block_palette.library_dir = Some(dir);
self.block_palette.library_entries = entries;
@@ -270,7 +293,11 @@ impl OpenCADStudio {
/// Insert `item` (a click, or a drop at `drop_at`), bringing its block
/// into the drawing first when it lives elsewhere.
pub(crate) fn palette_insert(&mut self, item: Item, drop_at: Option<glam::DVec3>) -> Task<Message> {
pub(crate) fn palette_insert(
&mut self,
item: Item,
drop_at: Option<glam::DVec3>,
) -> Task<Message> {
match self.resolve_palette_block(&item) {
Ok(name) => {
self.refresh_block_palette();
@@ -286,7 +313,12 @@ impl OpenCADStudio {
}
fn block_exists(&self, name: &str) -> bool {
self.tabs[self.active_tab].scene.document.block_records.get(name).is_some()
self.tabs[self.active_tab]
.scene
.document
.block_records
.get(name)
.is_some()
}
fn current_drawing_path(&self) -> String {
@@ -395,12 +427,16 @@ impl OpenCADStudio {
MenuAction::Insert => return self.palette_insert(item, None),
MenuAction::Redefine => {
let result = match &item {
Item::Ref(r) if r.drawing => self.import_drawing_block(PathBuf::from(&r.source), true),
Item::Ref(r) if r.drawing => {
self.import_drawing_block(PathBuf::from(&r.source), true)
}
Item::File(path) => self.import_drawing_block(path.clone(), true),
Item::Ref(r) if !r.source.is_empty() => crate::io::load_file(Path::new(&r.source))
.map_err(|e| e.to_string())
.and_then(|doc| self.import_block_definition(&doc, &r.name, true))
.map(|_| r.name.clone()),
Item::Ref(r) if !r.source.is_empty() => {
crate::io::load_file(Path::new(&r.source))
.map_err(|e| e.to_string())
.and_then(|doc| self.import_block_definition(&doc, &r.name, true))
.map(|_| r.name.clone())
}
_ => return Task::none(),
};
match result {
@@ -430,14 +466,22 @@ impl OpenCADStudio {
MenuAction::Edit => {
let name = item.name();
let i = self.active_tab;
let reference = self.tabs[i].scene.document.entities().find_map(|e| match e {
codec::EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(&name) => {
Some(ins.common.handle)
}
_ => None,
});
let reference = self.tabs[i]
.scene
.document
.entities()
.find_map(|e| match e {
codec::EntityType::Insert(ins)
if ins.block_name.eq_ignore_ascii_case(&name) =>
{
Some(ins.common.handle)
}
_ => None,
});
match reference {
Some(handle) => return self.dispatch_command(&format!("BEDIT_BEGIN:{}", handle.value())),
Some(handle) => {
return self.dispatch_command(&format!("BEDIT_BEGIN:{}", handle.value()))
}
None => self.command_line.push_error(&crate::tf!(
"Block \"{name}\" has no reference in the drawing to edit."
)),
@@ -458,7 +502,8 @@ impl OpenCADStudio {
self.push_undo_snapshot(i, "PURGE");
crate::app::control::erase_block_definition(&mut self.tabs[i].scene, &name);
self.tabs[i].dirty = true;
self.command_line.push_output(&crate::tf!("Block \"{name}\" removed."));
self.command_line
.push_output(&crate::tf!("Block \"{name}\" removed."));
self.refresh_block_palette();
}
}
@@ -492,12 +537,20 @@ impl OpenCADStudio {
/// Put `name` at the top of the Recent list (BLOCKMRULIST long).
pub(crate) fn note_recent_block(&mut self, name: &str) {
let (source, drawing) = match self.block_palette.file_sources.get(&name.to_ascii_uppercase()) {
let (source, drawing) = match self
.block_palette
.file_sources
.get(&name.to_ascii_uppercase())
{
Some(path) => (path.clone(), true),
None => (self.current_drawing_path(), false),
};
let entry = PaletteBlockRef {
name: if drawing { file_stem(Path::new(&source)) } else { name.to_string() },
name: if drawing {
file_stem(Path::new(&source))
} else {
name.to_string()
},
source,
time: now_secs(),
drawing,
@@ -509,7 +562,9 @@ impl OpenCADStudio {
}
pub(crate) fn trim_recent_blocks(&mut self) {
self.block_palette.recent.truncate(self.block_mru_list as usize);
self.block_palette
.recent
.truncate(self.block_mru_list as usize);
}
/// Rebuild the Current Drawing list, and the previews kept for blocks
@@ -541,7 +596,9 @@ impl OpenCADStudio {
let here = self.current_drawing_path();
for entry in &entries {
let key = format!("{here}|{}", entry.name.to_ascii_uppercase());
self.block_palette.ref_wires.insert(key, entry.wires.clone());
self.block_palette
.ref_wires
.insert(key, entry.wires.clone());
}
self.block_palette.blocks = entries;
let drawings: Vec<String> = self
@@ -580,7 +637,10 @@ impl OpenCADStudio {
let mut unnamed = 0;
let mut units = rustc_hash::FxHashMap::default();
for br in doc.block_records.iter() {
if br.flags.is_xref || br.name.eq_ignore_ascii_case("*Model_Space") || br.name.to_ascii_uppercase().starts_with("*PAPER_SPACE") {
if br.flags.is_xref
|| br.name.eq_ignore_ascii_case("*Model_Space")
|| br.name.to_ascii_uppercase().starts_with("*PAPER_SPACE")
{
continue;
}
if br.name.starts_with('*') {
@@ -602,6 +662,11 @@ impl OpenCADStudio {
let search_path = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
BlockCatalog { user, unnamed, units, search_path }
BlockCatalog {
user,
unnamed,
units,
search_path,
}
}
}
+3344 -3055
View File
File diff suppressed because it is too large. Load diff
+129 -41
View File
@@ -71,7 +71,8 @@ impl OpenCADStudio {
.scene
.selection
.borrow()
.menu.ui
.menu
.ui
.open_submenu;
build_context_menu(&self.context_menu_context(), open)
}
@@ -88,18 +89,22 @@ impl OpenCADStudio {
self.focus_cmd_input()
}
pub(in crate::app) fn on_context_menu_submenu_toggle(&mut self, id: SubmenuId) -> Task<Message> {
pub(in crate::app) fn on_context_menu_submenu_toggle(
&mut self,
id: SubmenuId,
) -> Task<Message> {
let i = self.active_tab;
let mut sel = self.tabs[i].scene.selection.borrow_mut();
let ui = &mut sel.menu.ui;
ui.open_submenu = if ui.open_submenu == Some(id) { None } else { Some(id) };
ui.open_submenu = if ui.open_submenu == Some(id) {
None
} else {
Some(id)
};
drop(sel);
// Keep the keyboard highlight on the header that was toggled.
let menu = self.current_context_menu();
let header = menu
.selectable()
.iter()
.position(|s| s.header == Some(id));
let header = menu.selectable().iter().position(|s| s.header == Some(id));
let mut sel = self.tabs[i].scene.selection.borrow_mut();
if sel.menu.ui.highlighted.is_some() {
sel.menu.ui.highlighted = header;
@@ -164,7 +169,10 @@ impl OpenCADStudio {
}
/// Keyboard navigation while the menu is open.
pub(in crate::app) fn on_context_menu_navigate(&mut self, nav: ContextMenuNav) -> Task<Message> {
pub(in crate::app) fn on_context_menu_navigate(
&mut self,
nav: ContextMenuNav,
) -> Task<Message> {
if !self.context_menu_open() {
return Task::none();
}
@@ -193,7 +201,13 @@ impl OpenCADStudio {
None => menu.default_index(),
Some(cur) => next_enabled(cur, 1),
};
self.tabs[i].scene.selection.borrow_mut().menu.ui.highlighted = Some(idx);
self.tabs[i]
.scene
.selection
.borrow_mut()
.menu
.ui
.highlighted = Some(idx);
Task::none()
}
ContextMenuNav::Up => {
@@ -201,7 +215,13 @@ impl OpenCADStudio {
None => menu.default_index(),
Some(cur) => next_enabled(cur, -1),
};
self.tabs[i].scene.selection.borrow_mut().menu.ui.highlighted = Some(idx);
self.tabs[i]
.scene
.selection
.borrow_mut()
.menu
.ui
.highlighted = Some(idx);
Task::none()
}
ContextMenuNav::Enter => {
@@ -220,8 +240,13 @@ impl OpenCADStudio {
if unique {
self.on_context_menu_pick(rows[idx].action.clone())
} else {
self.tabs[i].scene.selection.borrow_mut().menu.ui.highlighted =
Some(idx);
self.tabs[i]
.scene
.selection
.borrow_mut()
.menu
.ui
.highlighted = Some(idx);
Task::none()
}
}
@@ -235,7 +260,10 @@ impl OpenCADStudio {
/// normal handlers (after closing the menu when the key is not a menu key,
/// so typing a command name or a coordinate just works — the menu gets
/// out of the way exactly as it does in commercial solutions).
pub(in crate::app) fn intercept_context_menu_key(&mut self, msg: &Message) -> Option<Task<Message>> {
pub(in crate::app) fn intercept_context_menu_key(
&mut self,
msg: &Message,
) -> Option<Task<Message>> {
match msg {
Message::ArrowKeyPressed { direction, .. }
| Message::CommandLineArrowProbe { direction, .. } => match direction {
@@ -247,7 +275,10 @@ impl OpenCADStudio {
let ui = self.tabs[i].scene.selection.borrow().menu.ui.clone();
let menu = self.current_context_menu();
let rows = menu.selectable();
let header = ui.highlighted.and_then(|c| rows.get(c)).and_then(|r| r.header);
let header = ui
.highlighted
.and_then(|c| rows.get(c))
.and_then(|r| r.header);
let want_open = matches!(direction, ArrowKey::Right);
match header {
Some(id) if (ui.open_submenu == Some(id)) != want_open => {
@@ -258,6 +289,7 @@ impl OpenCADStudio {
}
},
Message::DynTabNext => Some(self.on_context_menu_navigate(ContextMenuNav::Down)),
Message::DynTabPrev => Some(self.on_context_menu_navigate(ContextMenuNav::Up)),
Message::CommandFinalize | Message::CommandSpace => {
if self.command_line.input.trim().is_empty() {
Some(self.on_context_menu_navigate(ContextMenuNav::Enter))
@@ -476,7 +508,11 @@ mod tests {
right_click(&mut app);
assert!(menu_open(&app));
let acts = menu_actions(&app);
assert_eq!(acts[0], MenuAction::Command("LINE".into()), "Repeat LINE first");
assert_eq!(
acts[0],
MenuAction::Command("LINE".into()),
"Repeat LINE first"
);
});
}
@@ -491,8 +527,8 @@ mod tests {
let _ = app.update(Message::ViewportRightPress);
{
let mut sel = app.tabs[0].scene.selection.borrow_mut();
sel.input.right_press_time = iced::time::Instant::now()
.checked_sub(std::time::Duration::from_millis(400));
sel.input.right_press_time =
iced::time::Instant::now().checked_sub(std::time::Duration::from_millis(400));
}
let _ = app.update(Message::ViewportRightRelease);
assert!(menu_open(&app));
@@ -558,7 +594,14 @@ mod tests {
let _ = app.update(Message::ContextMenuPick(MenuAction::Command("LINE".into())));
assert!(!menu_open(&app));
assert_eq!(active(&app), Some("LINE"));
assert!(!app.tabs[0].scene.selection.borrow().input.right_click_entered);
assert!(
!app.tabs[0]
.scene
.selection
.borrow()
.input
.right_click_entered
);
});
}
@@ -601,9 +644,11 @@ mod tests {
let _ = app.update(Message::CommandAppendChar("c".into()));
assert!(!menu_open(&app));
assert_eq!(active(&app), None);
let closed = app.tabs[0].scene.document.entities().any(
|e| matches!(e, codec::EntityType::LwPolyline(p) if p.is_closed),
);
let closed = app.tabs[0]
.scene
.document
.entities()
.any(|e| matches!(e, codec::EntityType::LwPolyline(p) if p.is_closed));
assert!(closed);
});
}
@@ -619,7 +664,11 @@ mod tests {
let _ = app.update(Message::CommandAppendChar("7".into()));
assert!(!menu_open(&app));
assert_eq!(active(&app), Some("LINE"));
assert!(app.command_line.input.contains('7'), "{:?}", app.command_line.input);
assert!(
app.command_line.input.contains('7'),
"{:?}",
app.command_line.input
);
});
}
@@ -635,7 +684,9 @@ mod tests {
let _ = app.update(Message::ContextMenuSubmenuToggle(SubmenuId::RecentInput));
let acts = menu_actions(&app);
assert!(acts.contains(&MenuAction::FeedInput("10,10".into())));
let _ = app.update(Message::ContextMenuPick(MenuAction::FeedInput("10,10".into())));
let _ = app.update(Message::ContextMenuPick(MenuAction::FeedInput(
"10,10".into(),
)));
assert!(!menu_open(&app));
let lines = app.tabs[0]
.scene
@@ -690,10 +741,12 @@ mod tests {
app.snapper.otrack_enabled = false;
app.ortho_mode = false;
app.polar_mode = false;
let handle = app.tabs[0].scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
)));
let handle = app.tabs[0]
.scene
.add_entity(EntityType::Line(Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
)));
app.tabs[0].scene.selected.insert(handle);
app.refresh_selected_grips();
app.tabs[0].active_grip = Some(crate::scene::pick::grip::GripEdit::single(
@@ -710,7 +763,10 @@ mod tests {
.scene
.camera
.borrow()
.project(world, iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)))
.project(
world,
iced::Rectangle::with_size(iced::Size::new(800.0, 600.0)),
)
.unwrap();
let _ = app.on_viewport_move(Point::new(cursor.x, cursor.y));
}
@@ -732,7 +788,10 @@ mod tests {
assert_eq!(acts[0], MenuAction::Enter);
assert!(acts.contains(&MenuAction::Grip(GripMenuCmd::Move)));
assert!(acts.contains(&MenuAction::Grip(GripMenuCmd::Exit)));
assert!(app.tabs[0].active_grip.is_some(), "the grip stays hot under the menu");
assert!(
app.tabs[0].active_grip.is_some(),
"the grip stays hot under the menu"
);
});
}
@@ -741,11 +800,17 @@ mod tests {
with_stack(|| {
let (mut app, _) = grip_app();
right_click(&mut app);
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Move)));
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(
GripMenuCmd::Move,
)));
assert!(!menu_open(&app));
assert!(app.tabs[0].active_grip.is_none());
assert_eq!(active(&app), Some("MOVE"));
let prompt = app.tabs[0].active_cmd.as_ref().map(|c| c.prompt()).unwrap_or_default();
let prompt = app.tabs[0]
.active_cmd
.as_ref()
.map(|c| c.prompt())
.unwrap_or_default();
let base_prompt = crate::tr!("command-move", "base", count = 1i64);
assert_ne!(prompt, base_prompt, "base already consumed");
assert_eq!(app.last_point, Some(DVec3::ZERO));
@@ -758,12 +823,20 @@ mod tests {
let (mut app, handle) = grip_app();
move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0));
let (x, y) = line_start(&app, handle);
assert!((x - 3.0).abs() < 1e-3 && (y - 4.0).abs() < 1e-3, "drag applied");
assert!(
(x - 3.0).abs() < 1e-3 && (y - 4.0).abs() < 1e-3,
"drag applied"
);
right_click(&mut app);
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Exit)));
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(
GripMenuCmd::Exit,
)));
assert!(app.tabs[0].active_grip.is_none());
let (x, y) = line_start(&app, handle);
assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "Exit restores the original");
assert!(
x.abs() < 1e-9 && y.abs() < 1e-9,
"Exit restores the original"
);
});
}
@@ -773,7 +846,9 @@ mod tests {
let (mut app, handle) = grip_app();
move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0));
right_click(&mut app);
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::Undo)));
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(
GripMenuCmd::Undo,
)));
let (x, y) = line_start(&app, handle);
assert!(x.abs() < 1e-9 && y.abs() < 1e-9);
let grip = app.tabs[0].active_grip.as_ref().expect("grip still hot");
@@ -786,7 +861,9 @@ mod tests {
with_stack(|| {
let (mut app, handle) = grip_app();
right_click(&mut app);
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::CopyToggle)));
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(
GripMenuCmd::CopyToggle,
)));
assert!(app.tabs[0].grip_copy);
move_cursor_to(&mut app, DVec3::new(3.0, 4.0, 0.0));
let _ = app.update(Message::CommandFinalize);
@@ -802,8 +879,13 @@ mod tests {
assert_eq!(lines.len(), 2, "{lines:?}");
let (x, y) = line_start(&app, handle);
assert!(x.abs() < 1e-9 && y.abs() < 1e-9, "original untouched");
assert!(lines.iter().any(|&(x, y)| (x - 3.0).abs() < 1e-3 && (y - 4.0).abs() < 1e-3));
assert!(app.tabs[0].active_grip.is_some(), "grip re-armed for the next copy");
assert!(lines
.iter()
.any(|&(x, y)| (x - 3.0).abs() < 1e-3 && (y - 4.0).abs() < 1e-3));
assert!(
app.tabs[0].active_grip.is_some(),
"grip re-armed for the next copy"
);
// One undo step removes the copy again.
app.undo_steps(1);
let count = app.tabs[0]
@@ -821,7 +903,9 @@ mod tests {
with_stack(|| {
let (mut app, handle) = grip_app();
right_click(&mut app);
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(GripMenuCmd::BasePoint)));
let _ = app.update(Message::ContextMenuPick(MenuAction::Grip(
GripMenuCmd::BasePoint,
)));
assert!(app.tabs[0].grip_base_pending);
move_cursor_to(&mut app, DVec3::new(1.0, 1.0, 0.0));
let _ = app.update(Message::ViewportLeftPress);
@@ -989,7 +1073,9 @@ mod transparent_tests {
})
.collect();
assert_eq!(lines.len(), 2);
assert!((lines[1].start.x - 50.0).abs() < 1e-6 && (lines[1].start.y - 0.0).abs() < 1e-6);
assert!(
(lines[1].start.x - 50.0).abs() < 1e-6 && (lines[1].start.y - 0.0).abs() < 1e-6
);
assert!((lines[1].end.x - 50.0).abs() < 1e-6 && (lines[1].end.y - 25.0).abs() < 1e-6);
// Point cursor in -X direction from (50, 25) using feed_active_cmd
@@ -1005,7 +1091,9 @@ mod transparent_tests {
})
.collect();
assert_eq!(lines.len(), 3);
assert!((lines[2].start.x - 50.0).abs() < 1e-6 && (lines[2].start.y - 25.0).abs() < 1e-6);
assert!(
(lines[2].start.x - 50.0).abs() < 1e-6 && (lines[2].start.y - 25.0).abs() < 1e-6
);
assert!((lines[2].end.x - 0.0).abs() < 1e-6 && (lines[2].end.y - 25.0).abs() < 1e-6);
});
}
+429 -286
View File
File diff suppressed because it is too large. Load diff
+43 -42
View File
@@ -4,13 +4,12 @@
use super::util::*;
use super::{format_size, VIEWCUBE_HIT_SIZE};
use crate::app::helpers::{
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis,
CoordKind,
parse_coord, polar_constrain_near, ucs_rotate_vec, ucs_to_wcs, ucs_z_axis, CoordKind,
};
use crate::app::{Message, OpenCADStudio, POLY_START_DELAY_MS};
use crate::modules::ModuleEvent;
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::model::object::GripApply;
use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit};
use crate::scene::{
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
};
@@ -83,9 +82,7 @@ impl OpenCADStudio {
.active_grip
.as_ref()
.map(|grip| match grip.mode {
crate::scene::pick::grip::GripEditMode::Stretch => {
(grip.origin_world, None)
}
crate::scene::pick::grip::GripEditMode::Stretch => (grip.origin_world, None),
crate::scene::pick::grip::GripEditMode::Lengthen => {
(grip.origin_world, Some(crate::command::DynRole::Distance))
}
@@ -111,10 +108,18 @@ impl OpenCADStudio {
if let Some((origin, scalar_role)) = grip_input {
let wanted_roles: Vec<crate::command::DynRole> = if rectangle_frame.is_some() {
vec![crate::command::DynRole::Width, crate::command::DynRole::Height]
vec![
crate::command::DynRole::Width,
crate::command::DynRole::Height,
]
} else {
scalar_role.map_or_else(
|| vec![crate::command::DynRole::Distance, crate::command::DynRole::Angle],
|| {
vec![
crate::command::DynRole::Distance,
crate::command::DynRole::Angle,
]
},
|role| vec![role],
)
};
@@ -259,8 +264,11 @@ impl OpenCADStudio {
}
// Derive the guide + anchor for the legacy field set so the overlay
// draws the right construction without each command opting in.
let comps: Vec<DynComponent> =
self.tabs[i].dyn_fields.iter().map(|f| f.component).collect();
let comps: Vec<DynComponent> = self.tabs[i]
.dyn_fields
.iter()
.map(|f| f.component)
.collect();
self.tabs[i].dyn_guide = match comps.as_slice() {
[DynComponent::Distance, DynComponent::Angle] | [DynComponent::Angle] => {
crate::command::DynGuide::Polar
@@ -278,13 +286,15 @@ impl OpenCADStudio {
pub(in crate::app) fn apply_dyn_spec(&mut self, i: usize, spec: crate::command::DynSpec) {
use crate::app::document::DynFieldEntry;
let new_roles: Vec<crate::command::DynRole> =
spec.fields.iter().map(|f| f.role).collect();
let new_roles: Vec<crate::command::DynRole> = spec.fields.iter().map(|f| f.role).collect();
let cur_roles: Vec<crate::command::DynRole> =
self.tabs[i].dyn_fields.iter().map(|f| f.role).collect();
if cur_roles != new_roles {
self.tabs[i].dyn_fields =
spec.fields.iter().map(|f| DynFieldEntry::from_role(f.role)).collect();
self.tabs[i].dyn_fields = spec
.fields
.iter()
.map(|f| DynFieldEntry::from_role(f.role))
.collect();
self.tabs[i].dyn_active = 0;
}
self.tabs[i].dyn_guide = spec.guide;
@@ -394,12 +404,12 @@ impl OpenCADStudio {
let dz = d_ucs.z;
let live_d = (dx * dx + dy * dy).sqrt();
let live_a = dy.atan2(dx); // radians, in the UCS plane
// A typed angle is shown unsigned (0..180); give it the sign of the
// cursor's current side so an entry made below the X axis sweeps
// downward to match the arc instead of mirroring up. An EXPLICIT
// `-`/`+` prefix keeps its literal sign though — "-30" must mean
// −30° (= 330°) regardless of where the cursor sits (#417).
// Untyped → live.
// A typed angle is shown unsigned (0..180); give it the sign of the
// cursor's current side so an entry made below the X axis sweeps
// downward to match the arc instead of mirroring up. An EXPLICIT
// `-`/`+` prefix keeps its literal sign though — "-30" must mean
// −30° (= 330°) regardless of where the cursor sits (#417).
// Untyped → live.
let angle_rad = |idx: usize| -> f64 {
let raw = fields[idx]
.buffer
@@ -420,9 +430,7 @@ impl OpenCADStudio {
// perpendicular through the anchor at that offset; the command projects
// it. Untyped tracks the cursor's signed offset; typed takes the
// cursor's side.
if let (Some(ref_pt), [DynComponent::Distance]) =
(self.tabs[i].dyn_ref, comps.as_slice())
{
if let (Some(ref_pt), [DynComponent::Distance]) = (self.tabs[i].dyn_ref, comps.as_slice()) {
let axis = (ref_pt - base).normalize_or_zero();
let perp = glam::DVec3::new(-axis.y, axis.x, 0.0);
let signed = (w - base).dot(perp);
@@ -449,22 +457,14 @@ impl OpenCADStudio {
Some(rel(glam::DVec3::new(val(0, dx), val(1, dy), 0.0)))
}
[DynComponent::X, DynComponent::Y] => {
Some(xf.to_wcs(glam::DVec3::new(
val(0, w_ucs.x),
val(1, w_ucs.y),
0.0,
)))
Some(xf.to_wcs(glam::DVec3::new(val(0, w_ucs.x), val(1, w_ucs.y), 0.0)))
}
[DynComponent::X, DynComponent::Y, DynComponent::Z] if relative => {
Some(rel(glam::DVec3::new(val(0, dx), val(1, dy), val(2, dz))))
}
[DynComponent::X, DynComponent::Y, DynComponent::Z] => {
Some(xf.to_wcs(glam::DVec3::new(
val(0, w_ucs.x),
val(1, w_ucs.y),
val(2, w_ucs.z),
)))
}
[DynComponent::X, DynComponent::Y, DynComponent::Z] => Some(xf.to_wcs(
glam::DVec3::new(val(0, w_ucs.x), val(1, w_ucs.y), val(2, w_ucs.z)),
)),
[DynComponent::Distance, DynComponent::Angle] => {
let d = val(0, live_d);
let a = angle_rad(1);
@@ -485,7 +485,11 @@ impl OpenCADStudio {
.as_deref()
.and_then(eval_dynamic_angle)
.unwrap_or(live_a);
Some(rel(glam::DVec3::new(live_d * a.cos(), live_d * a.sin(), 0.0)))
Some(rel(glam::DVec3::new(
live_d * a.cos(),
live_d * a.sin(),
0.0,
)))
}
_ => None,
}
@@ -641,7 +645,6 @@ impl OpenCADStudio {
}
}
pub(in crate::app) fn try_dyn_commit(&mut self) -> Option<Task<Message>> {
let i = self.active_tab;
if !self.dyn_input
@@ -679,6 +682,7 @@ impl OpenCADStudio {
self.dyn_coord_absolute = false;
self.sync_dyn_fields();
self.reset_tracking_after_point();
self.snapper.remember_distance_reference(dir);
self.push_ucs_to_cmd(i);
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
let task = result.map(|r| self.apply_cmd_result(r))?;
@@ -818,16 +822,13 @@ mod tests {
context.aunits = 2;
set_unit_context(context);
assert!(
(eval_dynamic_angle("2 * 50").unwrap() - std::f64::consts::FRAC_PI_2).abs()
< 1e-12
(eval_dynamic_angle("2 * 50").unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12
);
context.aunits = 3;
set_unit_context(context);
assert!(
(eval_dynamic_angle("1.5707963267948966").unwrap()
- std::f64::consts::FRAC_PI_2)
.abs()
(eval_dynamic_angle("1.5707963267948966").unwrap() - std::f64::consts::FRAC_PI_2).abs()
< 1e-12
);
+1034 -210
View File
File diff suppressed because it is too large. Load diff
+416 -174
View File
@@ -24,6 +24,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool {
| Message::CommandLineArrowProbe { .. }
| Message::CommandLineArrowResolved { .. }
| Message::DynTabNext
| Message::DynTabPrev
| Message::MTextCaretMove(_)
| Message::DeleteSelected
| Message::ToggleSnapEnabled
@@ -90,9 +91,9 @@ fn reorder_insertion_index(from: usize, to: usize, after: bool, len: usize) -> O
(insertion != from).then_some(insertion)
}
mod blocks_palette;
mod command;
mod context_menu;
mod blocks_palette;
mod dialog;
mod dynamic;
mod file;
@@ -352,7 +353,8 @@ impl OpenCADStudio {
2 => "ATTMODE2",
_ => "ATTMODE1",
};
self.ribbon.set_dropdown_current("ATTMODE_DROPDOWN", attmode);
self.ribbon
.set_dropdown_current("ATTMODE_DROPDOWN", attmode);
}
fn update_message(&mut self, msg: Message) -> Task<Message> {
@@ -382,8 +384,7 @@ impl OpenCADStudio {
{
// Esc backs out of a pending ALIASEDIT draft first, mirroring
// the shortcut editor's capture cancel; the next Esc closes.
if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add
{
if self.active_modal == Some(super::ModalKind::Aliases) && self.alias_pending_add {
return self.update(Message::AliasEditorDraftCancel);
}
if self.active_modal == Some(super::ModalKind::BlockDefinition) {
@@ -445,9 +446,12 @@ impl OpenCADStudio {
// a mouse move included — without allocating.
#[cfg(not(target_arch = "wasm32"))]
let active_command = |app: &Self| -> Option<(u64, Option<&'static str>)> {
app.tabs
.get(app.active_tab)
.map(|tab| (tab.id, tab.active_cmd.as_ref().map(|command| command.name())))
app.tabs.get(app.active_tab).map(|tab| {
(
tab.id,
tab.active_cmd.as_ref().map(|command| command.name()),
)
})
};
#[cfg(not(target_arch = "wasm32"))]
let command_before = active_command(self);
@@ -544,7 +548,9 @@ impl OpenCADStudio {
}
}
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
let elapsed_us = started.elapsed().as_micros() as u64;
crate::perf::note_update_time(elapsed_us);
let elapsed_ms = elapsed_us as f64 / 1000.0;
if elapsed_ms >= 5.0 {
crate::perf_record!("[perf] update {:>7.1}ms message={perf_label}", elapsed_ms,);
}
@@ -575,9 +581,8 @@ impl OpenCADStudio {
Message::SpaceMouseWake => self.on_spacemouse_wake(),
Message::TrackpadPinch(magnification) => self.on_pinch_zoom(magnification),
Message::SpaceMouseFrame(time) => {
self.spacemouse.frame(
time.saturating_duration_since(self.start).as_secs_f64() * 1000.,
);
self.spacemouse
.frame(time.saturating_duration_since(self.start).as_secs_f64() * 1000.);
Task::none()
}
Message::SpaceMouseFocus(id, focused) => {
@@ -629,9 +634,8 @@ impl OpenCADStudio {
if response["ok"] == false {
let code = response["code"].as_str().unwrap_or("failed");
let detail = response["error"].as_str().unwrap_or("");
self.command_line.push_error(&format!(
"automation {op}: {code} — {detail}"
));
self.command_line
.push_error(&format!("automation {op}: {code} — {detail}"));
} else if matches!(
response["status"].as_str(),
Some("completed" | "cancelled" | "waiting_input")
@@ -977,7 +981,8 @@ impl OpenCADStudio {
.scene
.selection
.borrow_mut()
.menu.open_at = None;
.menu
.open_at = None;
// Same guard as typed MTP/M2P: point step, not entity pick.
let i = self.active_tab;
let allowed = self.tabs[i].active_cmd.as_ref().is_some_and(|c| {
@@ -1237,19 +1242,23 @@ impl OpenCADStudio {
Task::none()
}
Message::ImageEmbedPick => {
Task::perform(crate::io::pick_embedded_image_file(), Message::ImageEmbedPickResult)
}
Message::ImageEmbedPick => Task::perform(
crate::io::pick_embedded_image_file(),
Message::ImageEmbedPickResult,
),
Message::ImageEmbedPickResult(Ok(image)) => {
use crate::command::CadCommand;
use crate::modules::draw::draw::raster_image::ImageCommand;
self.command_line.push_output(crate::tf!(
"IMAGEEMBED \"{name}\": {w}×{h} px (embedded)",
name = image.name.as_str(),
w = image.pixel_width,
h = image.pixel_height,
).as_ref());
self.command_line.push_output(
crate::tf!(
"IMAGEEMBED \"{name}\": {w}×{h} px (embedded)",
name = image.name.as_str(),
w = image.pixel_width,
h = image.pixel_height,
)
.as_ref(),
);
let cmd = ImageCommand::new_embedded(image);
let i = self.active_tab;
self.command_line.push_info(&cmd.prompt());
@@ -1336,10 +1345,13 @@ impl OpenCADStudio {
.insert(crate::io::font_repo::font_key(name));
}
for (name, path) in &pairs {
self.command_line.push_output(crate::tf!(
"FONT Downloaded {name} → {path}",
path = path.display()
).as_ref());
self.command_line.push_output(
crate::tf!(
"FONT Downloaded {name} → {path}",
path = path.display()
)
.as_ref(),
);
}
if !unavailable.is_empty() {
self.command_line.push_info(crate::tf!(
@@ -1355,14 +1367,16 @@ impl OpenCADStudio {
if let Some(path) = path {
return Task::done(Message::OpenExternal(path));
}
self.command_line.push_info(crate::t!(
"Save and reopen the drawing to apply the new fonts."
).as_ref());
self.command_line.push_info(
crate::t!("Save and reopen the drawing to apply the new fonts.")
.as_ref(),
);
}
Err(e) => {
// Keep the prompt open: the user can correct a custom
// source or retry a transient network failure.
self.command_line.push_error(crate::tf!("Font download failed: {e}").as_ref());
self.command_line
.push_error(crate::tf!("Font download failed: {e}").as_ref());
}
}
Task::none()
@@ -1900,9 +1914,9 @@ impl OpenCADStudio {
self.refresh_layer_panel();
self.sync_ribbon_styles();
}
Err(error) => self.command_line.push_error(
error["error"].as_str().unwrap_or("template"),
),
Err(error) => self
.command_line
.push_error(error["error"].as_str().unwrap_or("template")),
}
}
Task::none()
@@ -2168,16 +2182,21 @@ impl OpenCADStudio {
let i = self.active_tab;
// Capture prompt/options while holding cmd borrow, then release before
// borrowing command_line to satisfy borrow checker.
let (should_update, opts, prompt) =
let (should_update, opts, prompt, suggestions) =
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
if cmd.on_live_input(&live_input) {
(true, cmd.options(), cmd.prompt())
let changed = cmd.on_live_input(&live_input);
let suggestions = cmd.text_suggestions(&live_input);
if changed {
(true, cmd.options(), cmd.prompt(), suggestions)
} else {
(false, Vec::new(), String::new())
(false, Vec::new(), String::new(), suggestions)
}
} else {
(false, Vec::new(), String::new())
(false, Vec::new(), String::new(), Vec::new())
};
self.command_line.set_contextual_suggestions(suggestions);
if should_update {
self.command_line.set_step_options(opts);
// Update pinned prompt text live without pushing new history entry
@@ -2206,6 +2225,12 @@ impl OpenCADStudio {
}
Message::DynTabNext => {
if self.command_line.has_contextual_suggestions()
&& self.command_line.autocomplete_next()
{
return Task::none();
}
let i = self.active_tab;
let n = self.tabs[i].dyn_fields.len();
if n > 0 {
@@ -2217,6 +2242,13 @@ impl OpenCADStudio {
self.focus_cmd_input()
}
Message::DynTabPrev => {
if self.command_line.has_contextual_suggestions() {
self.command_line.autocomplete_prev();
}
self.focus_cmd_input()
}
Message::SplitModelViewport(horizontal) => {
let i = self.active_tab;
self.tabs[i].scene.split_active_pane(horizontal);
@@ -2253,7 +2285,8 @@ impl OpenCADStudio {
}
let i = self.active_tab;
if !self.command_line.history_navigation_active()
&& self.tabs[i].active_cmd.is_none()
&& (self.tabs[i].active_cmd.is_none()
|| self.command_line.has_contextual_suggestions())
&& self.command_line.autocomplete_prev()
{
return Task::none();
@@ -2279,7 +2312,8 @@ impl OpenCADStudio {
}
let i = self.active_tab;
if !self.command_line.history_navigation_active()
&& self.tabs[i].active_cmd.is_none()
&& (self.tabs[i].active_cmd.is_none()
|| self.command_line.has_contextual_suggestions())
&& self.command_line.autocomplete_next()
{
return Task::none();
@@ -2480,11 +2514,20 @@ impl OpenCADStudio {
}
}
Message::CommandSuggestionPick(cmd) => {
Message::CommandSuggestionPick(value) => {
self.command_line.close_history();
if self.tabs[self.active_tab].active_cmd.is_some()
&& self.command_line.has_contextual_suggestions()
{
self.command_line.input = value;
self.command_line.autocomplete_cursor = None;
return self.on_command_submit();
}
self.command_line.input.clear();
self.command_line.autocomplete_cursor = None;
self.command_line.close_history();
self.dispatch_command(&cmd)
self.dispatch_command(&value)
}
Message::CommandOptionPick(kw) => {
@@ -2586,7 +2629,8 @@ impl OpenCADStudio {
use crate::app::config::DockSide;
use crate::ui::dock::PanelId;
if self.dock.location(PanelId::ExternalReferences).is_none() {
self.dock.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX);
self.dock
.dock(PanelId::ExternalReferences, DockSide::Right, usize::MAX);
}
self.dock_expanded = Some(PanelId::ExternalReferences);
self.refresh_xref_manager();
@@ -2597,7 +2641,7 @@ impl OpenCADStudio {
self.refresh_xref_manager();
Task::none()
}
Message::XrefManagerSelect(index) => {
Message::XrefManagerSelect(id) => {
use crate::ui::window::xref_manager::SelectExtend;
// Normal GUI list behavior off the globally tracked
// modifiers (same source as the layer list): plain click
@@ -2609,12 +2653,12 @@ impl OpenCADStudio {
} else {
SelectExtend::Single
};
self.xref_manager.click_select(index, extend);
self.xref_manager.click_select(&id, extend);
Task::none()
}
Message::XrefRowRightClick(index) => {
Message::XrefRowRightClick(id) => {
self.xref_manager.row_change_path_open = false;
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
Task::none()
}
Message::XrefManagerToggleTree => {
@@ -2724,16 +2768,13 @@ impl OpenCADStudio {
};
let new_raw = path.to_string_lossy().into_owned();
self.push_undo_snapshot(i, "XREF-PATH");
match crate::io::xref::set_ref_path(
&mut self.tabs[i].scene.document,
key,
&new_raw,
) {
match crate::io::xref::set_ref_path(&mut self.tabs[i].scene.document, key, &new_raw)
{
Ok(name) => {
self.command_line.push_output(crate::tf!(
"XREF: Path set for \"{}\" — Reload to apply.",
name
).as_ref());
self.command_line.push_output(
crate::tf!("XREF: Path set for \"{}\" — Reload to apply.", name)
.as_ref(),
);
self.post_ref_op(i);
}
Err(msg) => self.command_line.push_error(msg.as_str()),
@@ -2743,7 +2784,8 @@ impl OpenCADStudio {
}
Message::XrefPathPickResult(Err(e)) => {
if e != "Cancelled" {
self.command_line.push_error(crate::tf!("XREF: {e}").as_ref());
self.command_line
.push_error(crate::tf!("XREF: {e}").as_ref());
}
Task::none()
}
@@ -2759,62 +2801,76 @@ impl OpenCADStudio {
self.xref_manager_op(op);
Task::none()
}
Message::XrefRowOp(index, op) => {
Message::XrefRowOp(id, op) => {
// Open is not a palette op — it navigates to the file.
if op == crate::ui::window::xref_manager::XrefPaletteOp::Open {
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
if let Some(entry) = self.xref_manager.entries.get(index) {
if let Some(found) = entry.found_at.clone() {
let is_dwg = found.to_ascii_lowercase().ends_with(".dwg")
|| found.to_ascii_lowercase().ends_with(".dxf");
self.command_line.push_output(crate::tf!("XOPEN: opening \"{}\".", found).as_ref());
if is_dwg {
return self.update(Message::OpenRecent(std::path::PathBuf::from(found)));
if let Some(index) = self.xref_manager.row_index(&id) {
if let Some(entry) = self.xref_manager.entries.get(index) {
if let Some(found) = entry.found_at.clone() {
let is_dwg = found.to_ascii_lowercase().ends_with(".dwg")
|| found.to_ascii_lowercase().ends_with(".dxf");
self.command_line.push_output(
crate::tf!("XOPEN: opening \"{}\".", found).as_ref(),
);
if is_dwg {
return self.update(Message::OpenRecent(
std::path::PathBuf::from(found),
));
} else {
#[cfg(not(target_arch = "wasm32"))]
let _ = open::that_detached(&found);
}
} else {
#[cfg(not(target_arch = "wasm32"))]
let _ = open::that_detached(&found);
self.command_line.push_error(
crate::t!("File not found — check the saved path.").as_ref(),
);
}
} else {
self.command_line.push_error(
crate::t!("File not found — check the saved path.").as_ref(),
);
}
}
return Task::none();
}
// Attach prompts the file dialog to pick a drawing to attach.
if matches!(op, crate::ui::window::xref_manager::XrefPaletteOp::Attach) {
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
self.command_line.push_output(crate::t!("XATTACH").as_ref());
return self.update(Message::XAttachPick);
}
// Row-scoped op: select the row first, then run the op.
self.xref_manager.right_click_select(index);
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
self.xref_manager_op(op);
Task::none()
}
Message::XrefRowPathPick(index) => {
self.xref_manager.right_click_select(index);
Message::XrefRowPathPick(id) => {
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
self.update(Message::XrefPathPick)
}
Message::XrefRowFindReplacePrompt(index) => {
self.xref_manager.right_click_select(index);
Message::XrefRowFindReplacePrompt(id) => {
self.xref_manager.right_click_select(&id);
self.xref_manager.row_change_path_open = false;
let prefill = if let Some(entry) = self.xref_manager.entries.get(index) {
let saved = &entry.saved_path;
if let Some(parent) = std::path::Path::new(saved).parent().and_then(|p| p.to_str()) {
if !parent.is_empty() {
format!("XREF Path Find \"{}\" ", parent)
} else {
"XREF Path Find ".to_string()
}
} else {
"XREF Path Find ".to_string()
}
let prefill = if let Some(index) = self.xref_manager.row_index(&id) {
self.xref_manager.entries.get(index).map_or_else(
|| "XREF Path Find ".to_string(),
|entry| {
let saved = &entry.saved_path;
if let Some(parent) = std::path::Path::new(saved)
.parent()
.and_then(|p| p.to_str())
{
if !parent.is_empty() {
format!("XREF Path Find \"{}\" ", parent)
} else {
"XREF Path Find ".to_string()
}
} else {
"XREF Path Find ".to_string()
}
},
)
} else {
"XREF Path Find ".to_string()
};
@@ -3148,20 +3204,15 @@ impl OpenCADStudio {
super::LayerStateProperty::Frozen => codec::LayerStateMask::FROZEN,
super::LayerStateProperty::Locked => codec::LayerStateMask::LOCKED,
super::LayerStateProperty::Plot => codec::LayerStateMask::PLOT,
super::LayerStateProperty::NewViewport => {
codec::LayerStateMask::NEW_VIEWPORT
}
super::LayerStateProperty::NewViewport => codec::LayerStateMask::NEW_VIEWPORT,
super::LayerStateProperty::Color => codec::LayerStateMask::COLOR,
super::LayerStateProperty::LineType => codec::LayerStateMask::LINE_TYPE,
super::LayerStateProperty::LineWeight => codec::LayerStateMask::LINE_WEIGHT,
super::LayerStateProperty::PlotStyle => codec::LayerStateMask::PLOT_STYLE,
super::LayerStateProperty::Transparency => {
codec::LayerStateMask::TRANSPARENCY
}
super::LayerStateProperty::Transparency => codec::LayerStateMask::TRANSPARENCY,
};
if let Some(state) = self.layer_state_edit_draft.as_mut() {
state.mask =
codec::LayerStateMask::from_bits(state.mask.bits() ^ flag.bits());
state.mask = codec::LayerStateMask::from_bits(state.mask.bits() ^ flag.bits());
}
Task::none()
}
@@ -3574,10 +3625,14 @@ impl OpenCADStudio {
panel.selected_multi.push(idx);
}
} else if shift {
// Shift-click selects the range from the anchor to here.
// Shift-click selects the range from the anchor to here —
// visible rows only, so filtered-out rows between them
// cannot join bulk edits/deletes (#50).
let anchor = panel.selected.unwrap_or(idx);
let (lo, hi) = (anchor.min(idx), anchor.max(idx));
panel.selected_multi = (lo..=hi).collect();
let visible: std::collections::HashSet<usize> =
panel.visible_indices().into_iter().collect();
panel.selected_multi = (lo..=hi).filter(|r| visible.contains(r)).collect();
} else if !panel.selected_multi.contains(&idx) {
// Plain click collapses to this row — but NOT when it is
// already part of a multi-selection, so clicking a property
@@ -3596,10 +3651,8 @@ impl OpenCADStudio {
};
self.tabs[i].layers.selected = Some(idx);
self.tabs[i].layers.selected_multi = vec![idx];
if let Some(layer) = self.tabs[i].layers.layers.get(idx) {
self.tabs[i].layers.edit_buf = layer.name.clone();
}
self.tabs[i].layers.editing = Some(idx);
self.tabs[i].layers.edit_buf = name.clone();
self.tabs[i].layers.editing = Some(name);
Task::none()
}
@@ -3617,11 +3670,11 @@ impl OpenCADStudio {
return Task::none();
};
let panel = &mut self.tabs[i].layers;
if panel.color_picker_row == Some(idx) {
if panel.color_picker_row.as_deref() == Some(name.as_str()) {
panel.color_picker_row = None;
panel.color_full_palette = false;
} else {
panel.color_picker_row = Some(idx);
panel.color_picker_row = Some(name);
panel.color_full_palette = false;
panel.selected = Some(idx);
// Opening the swatch on a row outside the current
@@ -3734,23 +3787,25 @@ impl OpenCADStudio {
// Apply the edited transparency to every selected layer (#236).
if let Some(v) = val {
let targets = self.layer_row_action_targets(i, idx);
for name in &targets {
if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) {
layer.transparency =
codec::types::Transparency::from_percent(v as f64 / 100.0);
}
if let Some(pl) = self.tabs[i]
.layers
.layers
.iter_mut()
.find(|l| &l.name == name)
{
pl.transparency = v;
}
}
if !targets.is_empty() {
let undo = self.begin_layer_undo(i, "LAYER TRANSPARENCY", &targets);
for name in &targets {
if let Some(layer) = self.tabs[i].scene.document.layers.get_mut(name) {
layer.transparency =
codec::types::Transparency::from_percent(v as f64 / 100.0);
}
if let Some(pl) = self.tabs[i]
.layers
.layers
.iter_mut()
.find(|l| &l.name == name)
{
pl.transparency = v;
}
}
self.tabs[i].scene.invalidate_layer_dependencies(&targets);
self.tabs[i].dirty = true;
self.commit_layer_undo(i, undo);
}
}
Task::none()
@@ -3779,7 +3834,8 @@ impl OpenCADStudio {
.scene
.selection
.borrow_mut()
.input.last_move_pos = Some(p);
.input
.last_move_pos = Some(p);
return Task::none();
}
self.focus_model_pane(idx);
@@ -3878,7 +3934,8 @@ impl OpenCADStudio {
let was_click = !sel.input.right_dragging;
// How long the button was held, for the time-sensitive mode.
let held_ms = sel
.input.right_press_time
.input
.right_press_time
.map_or(0, |t| t.elapsed().as_millis() as i32);
sel.input.right_down = false;
sel.input.right_press_pos = None;
@@ -4073,7 +4130,8 @@ impl OpenCADStudio {
.scene
.selection
.borrow()
.input.last_move_pos
.input
.last_move_pos
.unwrap_or(self.cursor_pos);
self.update_grip_hover(i, p);
Task::none()
@@ -4479,7 +4537,9 @@ impl OpenCADStudio {
continue;
};
let current = (
crate::scene::pe_url_of(entity).unwrap_or_default().to_owned(),
crate::scene::pe_url_of(entity)
.unwrap_or_default()
.to_owned(),
crate::scene::pe_url_description_of(entity)
.unwrap_or_default()
.to_owned(),
@@ -4584,28 +4644,36 @@ impl OpenCADStudio {
}
Message::AnnoObjectScaleToggle(name) => {
let i = self.active_tab;
if let Some(entity) = self.anno_object_scale_target {
if self.tabs[i].scene.is_layer_locked(entity) {
return Task::none();
}
if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) {
self.push_undo_snapshot(i, "OBJECTSCALE");
let doc = &mut self.tabs[i].scene.document;
let is_member =
crate::scene::annotative::object_scale_memberships(doc, entity)
.iter()
.any(|(_, h)| *h == sh);
if is_member {
crate::scene::annotative::remove_annotation_context_for_scale(
doc, entity, sh,
);
} else {
crate::scene::annotative::create_annotation_context(doc, entity, sh);
}
self.tabs[i].dirty = true;
self.invalidate_property_targets(i, &[entity]);
self.refresh_properties();
let Some(entity) = self.anno_object_scale_target else {
self.command_line.push_info(
crate::t!("Select one object first, then run OBJECTSCALE.").as_ref(),
);
return Task::none();
};
if self.tabs[i].scene.is_layer_locked(entity) {
self.command_line
.push_info(crate::t!("The object is on a locked layer.").as_ref());
return Task::none();
}
if let Some(sh) = self.tabs[i].scene.scale_handle_ensuring(&name) {
self.push_undo_snapshot(i, "OBJECTSCALE");
let doc = &mut self.tabs[i].scene.document;
let is_member = crate::scene::annotative::object_scale_memberships(doc, entity)
.iter()
.any(|(_, h)| *h == sh);
if is_member {
crate::scene::annotative::remove_annotation_context_for_scale(
doc, entity, sh,
);
} else {
crate::scene::annotative::create_annotation_context(doc, entity, sh);
}
self.tabs[i].dirty = true;
self.invalidate_property_targets(i, &[entity]);
self.refresh_properties();
} else {
self.command_line
.push_info(crate::tf!("Unknown annotation scale '{name}'.").as_ref());
}
Task::none()
}
@@ -4931,7 +4999,9 @@ impl OpenCADStudio {
self.tabs[i].scene.bump_geometry();
self.refresh_properties();
crate::entities::common::set_unit_context(
crate::entities::common::UnitContext::from_header(&self.tabs[i].scene.document.header),
crate::entities::common::UnitContext::from_header(
&self.tabs[i].scene.document.header,
),
);
Task::none()
}
@@ -5090,12 +5160,14 @@ impl OpenCADStudio {
};
let name = state.name.trim().to_string();
if name.is_empty() {
state.error_message = Some(crate::t!("Block name cannot be empty.").into_owned());
state.error_message =
Some(crate::t!("Block name cannot be empty.").into_owned());
return Task::none();
}
if let Some(_ch) = state.invalid_name_char() {
state.error_message = Some(
crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `").into_owned(),
crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `")
.into_owned(),
);
return Task::none();
}
@@ -5106,13 +5178,21 @@ impl OpenCADStudio {
}
if !state.objects_specify_onscreen && state.selected_handles.is_empty() {
state.error_message = Some(
crate::t!("No objects selected. You must select objects to define a block.")
.into_owned(),
crate::t!(
"No objects selected. You must select objects to define a block."
)
.into_owned(),
);
return Task::none();
}
let i = self.active_tab;
if self.tabs[i].scene.document.block_records.get(&name).is_some() {
if self.tabs[i]
.scene
.document
.block_records
.get(&name)
.is_some()
{
state.confirm_redefine = Some(name);
return Task::none();
}
@@ -5138,12 +5218,16 @@ impl OpenCADStudio {
let current_path = std::path::Path::new(&state.file_path);
let file_stem = current_path.file_stem().and_then(|s| s.to_str());
let is_default_or_block = file_stem == Some("new_block")
|| state.existing_blocks.iter().any(|b| Some(b.as_str()) == file_stem);
|| state
.existing_blocks
.iter()
.any(|b| Some(b.as_str()) == file_stem);
if is_default_or_block {
let new_file_name = format!("{}.dwg", trimmed);
if let Some(parent) = current_path.parent() {
if !parent.as_os_str().is_empty() {
state.file_path = parent.join(new_file_name).to_string_lossy().to_string();
state.file_path =
parent.join(new_file_name).to_string_lossy().to_string();
} else {
state.file_path = new_file_name;
}
@@ -5264,12 +5348,19 @@ impl OpenCADStudio {
WblockSourceMode::Block => {
let name = state.block_name.trim();
if name.is_empty() {
state.error_message =
Some(crate::t!("Please select or enter a block name.").into_owned());
state.error_message = Some(
crate::t!("Please select or enter a block name.").into_owned(),
);
return Task::none();
}
let i = self.active_tab;
if self.tabs[i].scene.document.block_records.get(name).is_none() {
if self.tabs[i]
.scene
.document
.block_records
.get(name)
.is_none()
{
state.error_message = Some(
crate::tf!("Block \"{}\" does not exist in drawing.", name)
.into_owned(),
@@ -5394,10 +5485,7 @@ impl OpenCADStudio {
}
WblockSourceMode::Objects => {
crate::modules::insert::wblock::extract_entities_to_doc_with_base(
&document,
&handles,
base_point,
unit,
&document, &handles, base_point, unit,
)
.map_err(|e| e.to_string())?
}
@@ -5456,7 +5544,9 @@ impl OpenCADStudio {
self.tabs[i].scene.bump_geometry();
self.refresh_properties();
crate::entities::common::set_unit_context(
crate::entities::common::UnitContext::from_header(&self.tabs[i].scene.document.header),
crate::entities::common::UnitContext::from_header(
&self.tabs[i].scene.document.header,
),
);
let label = crate::modules::draw::units::linear_format_label(code);
self.command_line
@@ -5786,8 +5876,7 @@ impl OpenCADStudio {
Task::none()
}
Message::AutoConstrainReset => {
self.auto_constrain_settings =
super::settings::AutoConstrainSettings::default();
self.auto_constrain_settings = super::settings::AutoConstrainSettings::default();
self.auto_constrain_selected_row = 0;
self.auto_constrain_distance_input =
format!("{}", self.auto_constrain_settings.distance_tolerance);
@@ -5826,8 +5915,7 @@ impl OpenCADStudio {
self.auto_constrain_settings.distance_tolerance = distance;
self.auto_constrain_settings.angle_tolerance_deg = angle;
self.auto_constrain_settings.sanitize();
self.auto_constrain_saved =
Some(self.auto_constrain_settings.clone());
self.auto_constrain_saved = Some(self.auto_constrain_settings.clone());
self.persist_settings_if_changed();
if matches!(action, Message::AutoConstrainOk) {
self.close_active_modal();
@@ -6871,11 +6959,12 @@ impl OpenCADStudio {
};
let Some((sx, sy, sz)) = scales else { return };
let eq = (sx - sy).abs() < 1e-12 && (sx - sz).abs() < 1e-12;
let checked = eq && !app.props_asym_scale.contains(&handle.value());
let checked =
eq && !app.tabs[i].props_asym_scale.contains(&handle.value());
if checked {
app.props_asym_scale.insert(handle.value());
app.tabs[i].props_asym_scale.insert(handle.value());
} else {
app.props_asym_scale.remove(&handle.value());
app.tabs[i].props_asym_scale.remove(&handle.value());
if let Some(codec::EntityType::Insert(ins)) =
app.tabs[i].scene.document.get_entity_mut(handle)
{
@@ -8405,6 +8494,18 @@ impl OpenCADStudio {
Task::none()
}
Message::TouchpadPanChanged(enabled) => {
self.touchpad_pan = enabled;
self.persist_settings_if_changed();
Task::none()
}
Message::UiScaleChanged(percent) => {
self.ui_scale_percent = crate::app::settings::clamp_ui_scale(percent);
self.persist_settings_if_changed();
Task::none()
}
Message::ZoomFactorChanged(factor) => {
self.zoom_factor = crate::app::settings::clamp_zoom_factor(factor);
// The field beside the slider reads the same value, so the
@@ -8685,7 +8786,9 @@ impl OpenCADStudio {
Message::CloseModal => {
// The Invalid Area dialog's ✕ undoes the boundary change, as its Cancel does.
if self.active_modal == Some(super::ModalKind::CountInvalidArea) {
return self.update(Message::Count(crate::ui::window::count_palette::CountMsg::InvalidUndo));
return self.update(Message::Count(
crate::ui::window::count_palette::CountMsg::InvalidUndo,
));
}
if self.active_modal == Some(super::ModalKind::SheetSet) {
self.close_sheet_set_dialog();
@@ -8702,7 +8805,11 @@ impl OpenCADStudio {
self.attdef_dialog = None;
let editing = self.attdef_edit.take().is_some();
self.close_active_modal();
return if editing { self.post_editor_closed(false) } else { Task::none() };
return if editing {
self.post_editor_closed(false)
} else {
Task::none()
};
}
// Cancelling the section settings drops the waiting extraction.
if self.active_modal == Some(super::ModalKind::PcSection) {
@@ -11122,6 +11229,136 @@ mod prop_pointer_tests {
}
}
#[cfg(test)]
mod anno_scale_toggle_tests {
// #59: silent no-op toggles must explain themselves on the command line.
use super::Message;
use crate::app::OpenCADStudio;
fn app_with_target() -> (OpenCADStudio, usize) {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let mut line = codec::entities::Line::new();
line.start = codec::types::Vector3::ZERO;
line.end = codec::types::Vector3::new(1.0, 0.0, 0.0);
let h = app
.commit_entity_handle(codec::EntityType::Line(line))
.expect("line commits");
app.anno_object_scale_target = Some(h);
(app, i)
}
#[test]
fn locked_layer_toggle_explains_itself() {
let (mut app, i) = app_with_target();
app.tabs[i]
.scene
.document
.layers
.get_mut("0")
.expect("layer 0 exists")
.flags
.locked = true;
let before = app.command_line.history.len();
let _ = app.update(Message::AnnoObjectScaleToggle("1:50".to_string()));
let new: Vec<String> = app.command_line.history[before..]
.iter()
.map(|e| e.text.clone())
.collect();
assert!(
new.iter().any(|t| t.to_lowercase().contains("locked")),
"locked-layer toggle must say so, got {new:?}"
);
}
#[test]
fn unknown_scale_toggle_explains_itself() {
let (mut app, _i) = app_with_target();
let before = app.command_line.history.len();
let _ = app.update(Message::AnnoObjectScaleToggle("NOPE".to_string()));
let new: Vec<String> = app.command_line.history[before..]
.iter()
.map(|e| e.text.clone())
.collect();
assert!(
!new.is_empty(),
"unknown-scale toggle must say something, got silence"
);
}
#[test]
fn missing_target_toggle_explains_itself() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.anno_object_scale_target = None;
let before = app.command_line.history.len();
let _ = app.update(Message::AnnoObjectScaleToggle("1:50".to_string()));
let new: Vec<String> = app.command_line.history[before..]
.iter()
.map(|e| e.text.clone())
.collect();
assert!(
!new.is_empty(),
"target-less toggle must say something, got silence"
);
}
}
#[cfg(test)]
mod dock_visibility_tests {
// #57: one visibility predicate for edge rendering and drag math. Pins
// the full 8-panel table so a newly added panel cannot silently diverge
// (behavior-preserving dedup: passes before and after by construction).
use crate::app::OpenCADStudio;
use crate::ui::dock::PanelId;
const ALL: [PanelId; 8] = [
PanelId::Properties,
PanelId::BlockPalette,
PanelId::ExternalReferences,
PanelId::Browser,
PanelId::NodeGraph,
PanelId::PointCloudManager,
PanelId::Count,
PanelId::SheetSetManager,
];
#[test]
fn dock_panel_visible_covers_every_panel() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.show_properties = true;
app.show_block_palette = true;
app.show_external_references = true;
app.show_browser = true;
app.show_node_graph = true;
app.pc_manager.show = true;
app.count_palette.show = true;
app.sheet_set.show = true;
for id in ALL {
assert!(app.dock_panel_visible(id), "{id:?} must be visible");
}
app.clean_screen = true;
for id in ALL {
assert!(
!app.dock_panel_visible(id),
"{id:?} must hide on clean screen"
);
}
}
#[test]
fn dock_panel_hidden_on_start_tab() {
// Fresh app, no drawing opened: the welcome tab shows no panels.
let app = OpenCADStudio::new_for_test();
assert!(app.tabs[app.active_tab].is_start);
for id in ALL {
assert!(!app.dock_panel_visible(id), "{id:?} must hide on Start");
}
}
}
#[cfg(test)]
mod free_text_entry_tests {
//! The free-form text prompts (table cell content, TEXT / MTEXT bodies)
@@ -11268,7 +11505,9 @@ mod free_text_entry_tests {
app.clipboard = vec![codec::EntityType::Line(codec::entities::Line::default())];
// A dialog field pasted natively: no command-line text, no PASTECLIP.
let _ = app.update(Message::PasteShortcutResolved(crate::app::PasteFocus::Field));
let _ = app.update(Message::PasteShortcutResolved(
crate::app::PasteFocus::Field,
));
assert_eq!(app.command_line.input, "EXISTING");
assert!(app.tabs[0].active_cmd.is_none());
}
@@ -11287,7 +11526,10 @@ mod free_text_entry_tests {
// Copied objects paste even though the command line holds focus.
app.clipboard = vec![codec::EntityType::Line(codec::entities::Line::default())];
let _ = app.update(Message::PasteShortcutResolved(PasteFocus::CommandLine));
assert!(app.tabs[0].active_cmd.as_ref().is_some_and(|cmd| cmd.name() == "PASTECLIP"));
assert!(app.tabs[0]
.active_cmd
.as_ref()
.is_some_and(|cmd| cmd.name() == "PASTECLIP"));
}
}
+42 -10
View File
@@ -48,7 +48,10 @@ impl OpenCADStudio {
self.plot_dialog.import_draft = Some(match result {
Ok((file, setups)) => PageSetupImportDraft {
file,
setups: setups.into_iter().map(|(name, ps)| (name, ps, true)).collect(),
setups: setups
.into_iter()
.map(|(name, ps)| (name, ps, true))
.collect(),
error: None,
},
Err(error) => PageSetupImportDraft {
@@ -179,8 +182,7 @@ impl OpenCADStudio {
match available.iter().find(|(n, _)| n.eq_ignore_ascii_case(want)) {
Some(found) => chosen.push(found.clone()),
None => self.command_line.push_error(
crate::tf!("PSETUPIN: no page setup named \"{want}\" in {file}.")
.as_ref(),
crate::tf!("PSETUPIN: no page setup named \"{want}\" in {file}.").as_ref(),
),
}
}
@@ -214,7 +216,11 @@ mod tests {
}
fn last_line(app: &OpenCADStudio) -> String {
app.command_line.history.last().map(|line| line.text.clone()).unwrap_or_default()
app.command_line
.history
.last()
.map(|line| line.text.clone())
.unwrap_or_default()
}
/// The fixture's page setups as the chooser would hold them, read
@@ -281,7 +287,10 @@ mod tests {
let site = app.tabs[i].scene.page_setup_get("Site").unwrap();
assert_eq!(site.paper_size, "ISO_A3_(297.00_x_420.00_MM)");
assert_eq!(site.rotation, PlotRotation::Degrees90);
assert!(site.plot_view_handle.is_null(), "foreign handles are dropped");
assert!(
site.plot_view_handle.is_null(),
"foreign handles are dropped"
);
assert!(app.tabs[i].dirty);
// Undo takes the whole import back.
let _ = app.update(Message::Undo);
@@ -314,20 +323,40 @@ mod tests {
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Loaded(loaded)));
let draft = app.plot_dialog.import_draft.as_ref().expect("chooser open");
assert_eq!(draft.file, "other.dwg");
assert_eq!(draft.selected().count(), 3, "everything ticked to start with");
assert_eq!(
draft.selected().count(),
3,
"everything ticked to start with"
);
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::All(false)));
assert_eq!(app.plot_dialog.import_draft.as_ref().unwrap().selected().count(), 0);
assert_eq!(
app.plot_dialog
.import_draft
.as_ref()
.unwrap()
.selected()
.count(),
0
);
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Toggle("B".into())));
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Toggle("C".into())));
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Apply));
assert!(app.plot_dialog.import_draft.is_none(), "Apply closes the chooser");
assert!(
app.plot_dialog.import_draft.is_none(),
"Apply closes the chooser"
);
assert_eq!(app.tabs[i].scene.page_setup_names(), ["B", "C"]);
assert_eq!(app.plot_dialog.selected_setup, "B");
assert!(app.plot_dialog.page_setups.iter().any(|name| name == "C"));
// A failed read shows its reason in the chooser; Cancel closes it.
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Loaded(Err("unreadable".into()))));
assert_eq!(
app.plot_dialog.import_draft.as_ref().unwrap().error.as_deref(),
app.plot_dialog
.import_draft
.as_ref()
.unwrap()
.error
.as_deref(),
Some("unreadable")
);
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Cancel));
@@ -340,7 +369,10 @@ mod tests {
let _ = app.update(Message::LayoutCreate);
assert_ne!(app.active_modal, Some(crate::app::ModalKind::Plot));
let _ = app.update(Message::PageSetupOnNewLayoutChanged(true));
assert!(app.current_config().plot.page_setup_on_new_layout, "persisted");
assert!(
app.current_config().plot.page_setup_on_new_layout,
"persisted"
);
let _ = app.update(Message::LayoutCreate);
assert_eq!(app.active_modal, Some(crate::app::ModalKind::Plot));
assert_eq!(app.plot_dialog.selected_setup, "*Layout3*");
+596 -587
View File
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -15,8 +15,7 @@ pub(super) fn sync_annotation_scale_header(scene: &mut Scene) {
};
let current = scene.document.header.current_annotation_scale.clone();
let current_matches = scene.scale_list().into_iter().any(|(name, factor, _)| {
name.eq_ignore_ascii_case(&current)
&& (factor - anno).abs() < 0.001 * anno.max(0.001)
name.eq_ignore_ascii_case(&current) && (factor - anno).abs() < 0.001 * anno.max(0.001)
});
let name = if current_matches {
current
+722 -452
View File
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -233,8 +233,7 @@ impl OpenCADStudio {
let saved_grid = self.snapper.grid_snap_on;
let saved_from = self.snapper.from_point;
self.snapper.grid_snap_on = false;
self.snapper.from_point =
saved_from.map(|p| frame.paper_to_model(p));
self.snapper.from_point = saved_from.map(|p| frame.paper_to_model(p));
let hit = self.snapper.snap(
model_cursor,
local,