forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into plugin/host-model-api
# Conflicts: # crates/ocs_plugin_api/src/process/v4.rs # src/app/update/mod.rs
This commit is contained in:
commit
8648225ac5
309 files changed
+81166
-4157
No files matched your search
+41
-16
@@ -573,6 +573,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
.scene
|
||||
.apply_grip(handle, grip_id, apply);
|
||||
}
|
||||
self.solve_grip_constraints(i, &grip);
|
||||
|
||||
// Keep GripEdit synchronized so the normal commit
|
||||
// path records the exact final position.
|
||||
@@ -726,6 +727,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
crate::app::expr_eval::eval_to_string(&raw)
|
||||
};
|
||||
self.command_line.input.clear();
|
||||
// Remember the token for the context menu's Recent Input
|
||||
// list (prose steps excluded: a table cell or text body
|
||||
// is not a reusable value).
|
||||
let is_prose = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.input_kind().is_free_text());
|
||||
if !is_prose {
|
||||
self.command_line.record_recent_input(&text);
|
||||
}
|
||||
|
||||
// Offer the typed text to the command's option handler
|
||||
// first (keywords like PLINE's A/L/C, a radius, …). If it
|
||||
@@ -931,6 +942,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
if grip_dyn_locked {
|
||||
return self.update(Message::CommandSubmit);
|
||||
}
|
||||
// Enter while a grip is hot places it where it is (or keeps
|
||||
// it hot when it has not moved) — it must not fall through to
|
||||
// "repeat the last command", which would discard the edit.
|
||||
if self.tabs[i].active_grip.is_some() && self.tabs[i].active_cmd.is_none() {
|
||||
return self.commit_active_grip_edit();
|
||||
}
|
||||
|
||||
// Normal command Dynamic Input commit.
|
||||
if let Some(task) = self.try_dyn_commit() {
|
||||
@@ -1722,15 +1739,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
.get_entity(popup.handle)
|
||||
.is_some_and(|entity| match (item.action, entity) {
|
||||
(GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => {
|
||||
!spline.cv_frame_visible
|
||||
&& crate::entities::spline::uses_fit_method(spline)
|
||||
crate::entities::spline::shows_fit_points(spline)
|
||||
}
|
||||
(
|
||||
GripMenuAction::ShowControlVertices,
|
||||
acadrust::EntityType::Spline(spline),
|
||||
) => {
|
||||
spline.cv_frame_visible
|
||||
|| !crate::entities::spline::uses_fit_method(spline)
|
||||
crate::entities::spline::shows_control_vertices(spline)
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
@@ -3248,15 +3263,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
self.tabs[i].properties.active_field = None;
|
||||
let handles = self.property_target_handles(i);
|
||||
if !handles.is_empty() {
|
||||
let driven_marker = match field {
|
||||
"start_x" | "start_y" | "start_z" => Some(0),
|
||||
"end_x" | "end_y" | "end_z" => Some(1),
|
||||
"center_x" | "center_y" | "center_z" => Some(-3),
|
||||
let driven_reference = match field {
|
||||
"start_x" | "start_y" | "start_z" => Some(Some(0)),
|
||||
"end_x" | "end_y" | "end_z" => Some(Some(1)),
|
||||
"center_x" | "center_y" | "center_z" => Some(Some(-3)),
|
||||
"radius" | "diameter" | "circumference" | "area"
|
||||
| "major_r" | "minor_r" | "ratio" => Some(None),
|
||||
_ => None,
|
||||
};
|
||||
let retained_originals: Vec<_> = if self.constraint_solve_mode
|
||||
&& driven_marker.is_some()
|
||||
{
|
||||
let retain_size = self.constraint_solve_mode
|
||||
&& driven_reference.is_some_and(|marker| marker.is_some());
|
||||
let retained_originals: Vec<_> = if retain_size {
|
||||
handles
|
||||
.iter()
|
||||
.filter_map(|handle| {
|
||||
@@ -3587,13 +3604,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let driven_refs: Vec<_> = driven_marker
|
||||
let driven_refs: Vec<_> = driven_reference
|
||||
.into_iter()
|
||||
.flat_map(|marker| {
|
||||
handles.iter().copied().map(move |handle| {
|
||||
crate::scene::parametric_constraints::ParametricRef::point(
|
||||
handle, marker,
|
||||
)
|
||||
handles.iter().copied().map(move |handle| match marker {
|
||||
Some(marker) => {
|
||||
crate::scene::parametric_constraints::ParametricRef::point(
|
||||
handle, marker,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
crate::scene::parametric_constraints::ParametricRef::whole(
|
||||
handle,
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -3601,6 +3625,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
i,
|
||||
&handles,
|
||||
&driven_refs,
|
||||
retain_size,
|
||||
&retained_originals,
|
||||
);
|
||||
self.tabs[i].dirty = true;
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
//! Viewport right-click context menu: gathering the menu's inputs from app
|
||||
//! state, running a picked row, and keyboard navigation while it is open.
|
||||
//!
|
||||
//! The rows themselves are built by `ui::popup::context_menu` (a pure model);
|
||||
//! this module is the glue between that model and the app's messages.
|
||||
|
||||
use crate::app::{ArrowKey, ContextMenuNav, Message, OpenCADStudio};
|
||||
use crate::command::StepInput;
|
||||
use crate::scene::pick::grip::GripEditMode;
|
||||
use crate::ui::popup::context_menu::{
|
||||
build_context_menu, ContextMenu, GripMenuCmd, GripMenuContext, MenuAction, MenuContext,
|
||||
SubmenuId,
|
||||
};
|
||||
use acadrust::Handle;
|
||||
use iced::Task;
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// True while the viewport context menu is open on the active tab.
|
||||
pub(in crate::app) fn context_menu_open(&self) -> bool {
|
||||
self.tabs
|
||||
.get(self.active_tab)
|
||||
.is_some_and(|tab| tab.scene.selection.borrow().context_menu.is_some())
|
||||
}
|
||||
|
||||
/// Everything the menu builder needs, read from the active tab. Called by
|
||||
/// the view (to render) and by the keyboard handler (to resolve picks), so
|
||||
/// both always see the same rows.
|
||||
pub(in crate::app) fn context_menu_context(&self) -> MenuContext {
|
||||
let tab = &self.tabs[self.active_tab];
|
||||
if let Some(grip) = tab.active_grip.as_ref() {
|
||||
return MenuContext::Grip(GripMenuContext {
|
||||
mode: grip.mode,
|
||||
copy_on: tab.grip_copy,
|
||||
moved: grip.last_world != grip.origin_world,
|
||||
});
|
||||
}
|
||||
if let Some(cmd) = tab.active_cmd.as_ref() {
|
||||
// Same guard as typed MTP/M2P and SnapOverrideMtp: the step must
|
||||
// take a point pick (possibly alongside keywords), not an entity.
|
||||
let has_point_step = (!cmd.input_kind().wants_text()
|
||||
|| cmd.point_step_accepts_keywords())
|
||||
&& !cmd.needs_entity_pick();
|
||||
return MenuContext::Command {
|
||||
options: cmd.options(),
|
||||
has_point_step,
|
||||
recent_inputs: self.command_line.recent_inputs.clone(),
|
||||
};
|
||||
}
|
||||
MenuContext::Idle {
|
||||
has_selection: !tab.scene.selected.is_empty(),
|
||||
selected_constraint: tab.scene.selected_constraint,
|
||||
isolation_active: tab.scene.is_isolation_active(),
|
||||
recent_cmds: self
|
||||
.command_line
|
||||
.recent_commands
|
||||
.iter()
|
||||
.rev()
|
||||
.take(crate::ui::popup::context_menu::RECENT_LIMIT)
|
||||
.cloned()
|
||||
.collect(),
|
||||
clipboard_nonempty: !self.clipboard.is_empty(),
|
||||
undo_label: tab.history.undo_stack.last().map(|s| s.label().to_string()),
|
||||
redo_label: tab.history.redo_stack.last().map(|s| s.label().to_string()),
|
||||
props_open: self.show_properties,
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu as currently shown (rows + expanded submenu).
|
||||
pub(in crate::app) fn current_context_menu(&self) -> ContextMenu {
|
||||
let open = self.tabs[self.active_tab]
|
||||
.scene
|
||||
.selection
|
||||
.borrow()
|
||||
.context_menu_ui
|
||||
.open_submenu;
|
||||
build_context_menu(&self.context_menu_context(), open)
|
||||
}
|
||||
|
||||
/// Close the menu and give the keyboard back to the command line.
|
||||
pub(in crate::app) fn close_context_menu(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||
if sel.context_menu.is_none() {
|
||||
return Task::none();
|
||||
}
|
||||
sel.close_context_menu();
|
||||
drop(sel);
|
||||
self.focus_cmd_input()
|
||||
}
|
||||
|
||||
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.context_menu_ui;
|
||||
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 mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||
if sel.context_menu_ui.highlighted.is_some() {
|
||||
sel.context_menu_ui.highlighted = header;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Run a picked row. The menu closes first so the action sees the same
|
||||
/// state a typed keyword / shortcut would (no menu open, command line
|
||||
/// focused), then the action is routed through the existing message for
|
||||
/// that behaviour.
|
||||
pub(in crate::app) fn on_context_menu_pick(&mut self, action: MenuAction) -> Task<Message> {
|
||||
if let MenuAction::ToggleSubmenu(id) = action {
|
||||
return self.on_context_menu_submenu_toggle(id);
|
||||
}
|
||||
let i = self.active_tab;
|
||||
{
|
||||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||
sel.close_context_menu();
|
||||
// A pick counts as "another interaction" for the Enter-first
|
||||
// right-click cycle: the next right-click acts as Enter again.
|
||||
sel.right_click_entered = false;
|
||||
}
|
||||
let focus = self.focus_cmd_input();
|
||||
let task = match action {
|
||||
MenuAction::Enter if self.tabs[i].active_grip.is_some() => {
|
||||
self.commit_active_grip_edit()
|
||||
}
|
||||
MenuAction::Enter => self.update(Message::CommandFinalize),
|
||||
MenuAction::Cancel => self.update(Message::CommandEscape),
|
||||
MenuAction::Option(kw) => self.update(Message::CommandOptionPick(kw)),
|
||||
MenuAction::FeedInput(token) => {
|
||||
if self.tabs[i].active_cmd.is_some() {
|
||||
self.feed_active_cmd(&token)
|
||||
} else {
|
||||
self.run_command_line(&token)
|
||||
}
|
||||
}
|
||||
MenuAction::Command(cmd) => self.update(Message::Command(cmd)),
|
||||
MenuAction::Mtp => self.update(Message::SnapOverrideMtp),
|
||||
MenuAction::SnapOverride(t) => self.update(Message::SnapOverridePick(t)),
|
||||
MenuAction::SnapOverrideNone => self.update(Message::SnapOverrideNone),
|
||||
MenuAction::DeleteSelected => self.update(Message::DeleteSelected),
|
||||
MenuAction::SelectSimilar => self.update(Message::SelectSimilar),
|
||||
MenuAction::InvertSelection => self.update(Message::InvertSelection),
|
||||
MenuAction::DeselectAll => {
|
||||
self.tabs[i].scene.deselect_all();
|
||||
self.refresh_properties();
|
||||
Task::none()
|
||||
}
|
||||
MenuAction::QuickSelect => self.update(Message::QSelectOpen),
|
||||
MenuAction::Properties => self.update(Message::ToggleProperties),
|
||||
MenuAction::Options => self.update(Message::OptionsOpen),
|
||||
MenuAction::Undo => self.update(Message::Undo),
|
||||
MenuAction::Redo => self.update(Message::Redo),
|
||||
MenuAction::DrawOrderPickRef(above) => self.update(Message::DrawOrderPickRef(above)),
|
||||
MenuAction::ConstraintDelete(id) => self.update(Message::PropConstraintDelete(id)),
|
||||
MenuAction::Grip(cmd) => self.on_grip_context_pick(cmd),
|
||||
MenuAction::ToggleSubmenu(_) => Task::none(),
|
||||
};
|
||||
Task::batch(vec![focus, task])
|
||||
}
|
||||
|
||||
/// Keyboard navigation while the menu is open.
|
||||
pub(in crate::app) fn on_context_menu_navigate(&mut self, nav: ContextMenuNav) -> Task<Message> {
|
||||
if !self.context_menu_open() {
|
||||
return Task::none();
|
||||
}
|
||||
let i = self.active_tab;
|
||||
let menu = self.current_context_menu();
|
||||
let rows = menu.selectable();
|
||||
if rows.is_empty() {
|
||||
return Task::none();
|
||||
}
|
||||
let current = self.tabs[i].scene.selection.borrow().context_menu_ui.highlighted;
|
||||
let next_enabled = |from: usize, step: isize| -> usize {
|
||||
let n = rows.len() as isize;
|
||||
let mut idx = from as isize;
|
||||
for _ in 0..n {
|
||||
idx = (idx + step).rem_euclid(n);
|
||||
if rows[idx as usize].enabled {
|
||||
return idx as usize;
|
||||
}
|
||||
}
|
||||
from
|
||||
};
|
||||
match nav {
|
||||
ContextMenuNav::Down => {
|
||||
let idx = match current {
|
||||
// First press lands on the default row itself.
|
||||
None => menu.default_index(),
|
||||
Some(cur) => next_enabled(cur, 1),
|
||||
};
|
||||
self.tabs[i].scene.selection.borrow_mut().context_menu_ui.highlighted = Some(idx);
|
||||
Task::none()
|
||||
}
|
||||
ContextMenuNav::Up => {
|
||||
let idx = match current {
|
||||
None => menu.default_index(),
|
||||
Some(cur) => next_enabled(cur, -1),
|
||||
};
|
||||
self.tabs[i].scene.selection.borrow_mut().context_menu_ui.highlighted = Some(idx);
|
||||
Task::none()
|
||||
}
|
||||
ContextMenuNav::Enter => {
|
||||
let idx = current.unwrap_or_else(|| menu.default_index());
|
||||
let row = &rows[idx];
|
||||
if !row.enabled {
|
||||
return Task::none();
|
||||
}
|
||||
self.on_context_menu_pick(row.action.clone())
|
||||
}
|
||||
ContextMenuNav::Mnemonic(ch) => match menu.find_mnemonic(ch, current) {
|
||||
Some(idx) => {
|
||||
// A letter shared by several rows (PLINE's CEnter / CLose)
|
||||
// cycles the highlight; a unique one picks at once.
|
||||
let unique = menu.find_mnemonic(ch, Some(idx)) == Some(idx);
|
||||
if unique {
|
||||
self.on_context_menu_pick(rows[idx].action.clone())
|
||||
} else {
|
||||
self.tabs[i].scene.selection.borrow_mut().context_menu_ui.highlighted =
|
||||
Some(idx);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
None => Task::none(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Route keyboard-derived messages to the open menu. Returns `Some(task)`
|
||||
/// when the message was consumed; `None` lets it fall through to the
|
||||
/// 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>> {
|
||||
match msg {
|
||||
Message::ArrowKeyPressed { direction, .. }
|
||||
| Message::CommandLineArrowProbe { direction, .. } => match direction {
|
||||
ArrowKey::Up => Some(self.on_context_menu_navigate(ContextMenuNav::Up)),
|
||||
ArrowKey::Down => Some(self.on_context_menu_navigate(ContextMenuNav::Down)),
|
||||
ArrowKey::Left | ArrowKey::Right => {
|
||||
// On a submenu header, Right expands and Left collapses.
|
||||
let i = self.active_tab;
|
||||
let ui = self.tabs[i].scene.selection.borrow().context_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 want_open = matches!(direction, ArrowKey::Right);
|
||||
match header {
|
||||
Some(id) if (ui.open_submenu == Some(id)) != want_open => {
|
||||
Some(self.on_context_menu_submenu_toggle(id))
|
||||
}
|
||||
_ => Some(Task::none()),
|
||||
}
|
||||
}
|
||||
},
|
||||
Message::DynTabNext => Some(self.on_context_menu_navigate(ContextMenuNav::Down)),
|
||||
Message::CommandFinalize | Message::CommandSpace => {
|
||||
if self.command_line.input.trim().is_empty() {
|
||||
Some(self.on_context_menu_navigate(ContextMenuNav::Enter))
|
||||
} else {
|
||||
// Typed text pending: Enter runs it, the menu just closes.
|
||||
let close = self.close_context_menu();
|
||||
let run = self.update(Message::CommandFinalize);
|
||||
Some(Task::batch(vec![close, run]))
|
||||
}
|
||||
}
|
||||
Message::CommandSubmit => {
|
||||
if self.command_line.input.trim().is_empty() {
|
||||
Some(self.on_context_menu_navigate(ContextMenuNav::Enter))
|
||||
} else {
|
||||
let _ = self.close_context_menu();
|
||||
None
|
||||
}
|
||||
}
|
||||
Message::CommandAppendChar(s) => self.context_menu_typed(s.chars().next()),
|
||||
Message::CommandInput(buf) => {
|
||||
// The focused command-line field delivers the whole buffer;
|
||||
// only a single appended character is a candidate mnemonic.
|
||||
let appended = buf
|
||||
.strip_prefix(self.command_line.input.as_str())
|
||||
.filter(|rest| rest.chars().count() == 1)
|
||||
.and_then(|rest| rest.chars().next());
|
||||
match appended {
|
||||
Some(ch) => self.context_menu_typed(Some(ch)),
|
||||
None => {
|
||||
let _ = self.close_context_menu();
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A printable key while the menu is open: a mnemonic picks its row;
|
||||
/// anything else closes the menu and is typed into the command line.
|
||||
fn context_menu_typed(&mut self, ch: Option<char>) -> Option<Task<Message>> {
|
||||
let ch = ch?;
|
||||
let menu = self.current_context_menu();
|
||||
if ch.is_ascii_alphanumeric() && menu.find_mnemonic(ch, None).is_some() {
|
||||
return Some(self.on_context_menu_navigate(ContextMenuNav::Mnemonic(ch)));
|
||||
}
|
||||
let _ = self.close_context_menu();
|
||||
None
|
||||
}
|
||||
|
||||
/// Grip-mode shortcut menu (the grip menu of commercial solutions): the grip stays hot
|
||||
/// for Stretch / Base Point / Copy / Undo; Move / Rotate / Scale / Mirror
|
||||
/// hand the selection to the matching command with the grip as its base
|
||||
/// point; Exit drops the edit.
|
||||
pub(in crate::app) fn on_grip_context_pick(&mut self, cmd: GripMenuCmd) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let Some(grip) = self.tabs[i].active_grip.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
match cmd {
|
||||
GripMenuCmd::Exit => {
|
||||
self.cancel_active_grip_edit();
|
||||
self.tabs[i].grip_copy = false;
|
||||
self.tabs[i].grip_base_pending = false;
|
||||
Task::none()
|
||||
}
|
||||
GripMenuCmd::Stretch => {
|
||||
if let Some(active) = self.tabs[i].active_grip.as_mut() {
|
||||
active.mode = GripEditMode::Stretch;
|
||||
active.axis = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
GripMenuCmd::Move | GripMenuCmd::Rotate | GripMenuCmd::Scale | GripMenuCmd::Mirror => {
|
||||
let base = grip.origin_world;
|
||||
let handles: Vec<Handle> = grip.targets.iter().map(|t| t.handle).collect();
|
||||
self.cancel_active_grip_edit();
|
||||
self.tabs[i].grip_copy = false;
|
||||
self.tabs[i].grip_base_pending = false;
|
||||
// The command edits the current selection; make sure the
|
||||
// gripped objects are in it.
|
||||
if self.tabs[i].scene.selected.is_empty() {
|
||||
self.tabs[i].scene.select_entities(&handles);
|
||||
}
|
||||
let name = match cmd {
|
||||
GripMenuCmd::Move => "MOVE",
|
||||
GripMenuCmd::Rotate => "ROTATE",
|
||||
GripMenuCmd::Scale => "SCALE",
|
||||
_ => "MIRROR",
|
||||
};
|
||||
let start = self.dispatch_command(name);
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
return start;
|
||||
}
|
||||
// The grip is the base point, so the command opens directly
|
||||
// at its second prompt.
|
||||
self.last_point = Some(base);
|
||||
let fed = self.feed_command(StepInput::Point(base));
|
||||
Task::batch(vec![start, fed])
|
||||
}
|
||||
GripMenuCmd::BasePoint => {
|
||||
self.tabs[i].grip_base_pending = true;
|
||||
Task::none()
|
||||
}
|
||||
GripMenuCmd::CopyToggle => {
|
||||
self.tabs[i].grip_copy = !self.tabs[i].grip_copy;
|
||||
Task::none()
|
||||
}
|
||||
GripMenuCmd::Undo => {
|
||||
// Put the shapes back where the gesture started and keep the
|
||||
// grip hot there (the Copy toggle survives).
|
||||
let copy = self.tabs[i].grip_copy;
|
||||
self.cancel_active_grip_edit();
|
||||
self.tabs[i].grip_copy = copy;
|
||||
let mut again = grip;
|
||||
let delta = again.last_world - again.origin_world;
|
||||
again.last_world = again.origin_world;
|
||||
for target in &mut again.targets {
|
||||
target.last_world -= delta;
|
||||
}
|
||||
self.tabs[i].active_grip = Some(again);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::settings::RightClickMode;
|
||||
use crate::command::{CadCommand, StepInput};
|
||||
use crate::ui::popup::context_menu::MenuAction;
|
||||
use glam::DVec3;
|
||||
use iced::Point;
|
||||
|
||||
/// `update` recursion (right-click → CommandFinalize → CommandSubmit →
|
||||
/// apply) is deeper than the default test-thread stack; production
|
||||
/// threads are sized for it (see `.cargo/config.toml`), so tests get an
|
||||
/// equivalent stack here.
|
||||
fn with_stack(f: impl FnOnce() + Send + 'static) {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(32 * 1024 * 1024)
|
||||
.spawn(f)
|
||||
.expect("spawn test thread")
|
||||
.join()
|
||||
.unwrap_or_else(|e| std::panic::resume_unwind(e));
|
||||
}
|
||||
|
||||
fn app() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0);
|
||||
let _ = app.update(Message::ViewportMove(Point::new(200.0, 150.0)));
|
||||
app
|
||||
}
|
||||
|
||||
fn start(app: &mut OpenCADStudio, cmd: &str, points: &[(f64, f64)]) {
|
||||
let _ = app.dispatch_command(cmd);
|
||||
for &(x, y) in points {
|
||||
let _ = app.feed_command(StepInput::Point(DVec3::new(x, y, 0.0)));
|
||||
}
|
||||
}
|
||||
|
||||
fn right_click(app: &mut OpenCADStudio) {
|
||||
let _ = app.update(Message::ViewportRightPress);
|
||||
let _ = app.update(Message::ViewportRightRelease);
|
||||
}
|
||||
|
||||
fn menu_open(app: &OpenCADStudio) -> bool {
|
||||
app.tabs[0].scene.selection.borrow().context_menu.is_some()
|
||||
}
|
||||
|
||||
fn active(app: &OpenCADStudio) -> Option<&'static str> {
|
||||
app.tabs[0].active_cmd.as_ref().map(|c| c.name())
|
||||
}
|
||||
|
||||
fn menu_actions(app: &OpenCADStudio) -> Vec<MenuAction> {
|
||||
app.current_context_menu()
|
||||
.selectable()
|
||||
.into_iter()
|
||||
.map(|s| s.action)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcut_menu_mode_single_rmb_opens_menu_during_command() {
|
||||
with_stack(|| {
|
||||
let mut app = app();
|
||||
assert_eq!(app.right_click_mode, RightClickMode::ShortcutMenu);
|
||||
start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]);
|
||||
right_click(&mut app);
|
||||
assert!(menu_open(&app));
|
||||
assert_eq!(active(&app), Some("LINE"));
|
||||
let acts = menu_actions(&app);
|
||||
assert_eq!(acts[0], MenuAction::Enter);
|
||||
assert_eq!(acts[1], MenuAction::Cancel);
|
||||
assert!(acts.contains(&MenuAction::Option("C".into())));
|
||||
assert!(acts.contains(&MenuAction::Option("U".into())));
|
||||
assert!(acts.contains(&MenuAction::ToggleSubmenu(SubmenuId::SnapOverrides)));
|
||||
assert!(acts.contains(&MenuAction::Command("'PAN".into())));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_first_mode_first_rmb_is_enter_second_opens_menu() {
|
||||
with_stack(|| {
|
||||
let mut app = app();
|
||||
app.right_click_mode = RightClickMode::EnterFirst;
|
||||
start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]);
|
||||
right_click(&mut app);
|
||||
// Enter ends LINE.
|
||||
assert!(!menu_open(&app));
|
||||
assert_eq!(active(&app), None);
|
||||
// Idle: the next right-click opens the menu.
|
||||
right_click(&mut app);
|
||||
assert!(menu_open(&app));
|
||||
let acts = menu_actions(&app);
|
||||
assert_eq!(acts[0], MenuAction::Command("LINE".into()), "Repeat LINE first");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_sensitive_quick_click_is_enter_and_hold_opens_menu() {
|
||||
with_stack(|| {
|
||||
let mut app = app();
|
||||
app.right_click_mode = RightClickMode::TimeSensitive;
|
||||
app.right_click_hold_ms = 250;
|
||||
start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]);
|
||||
// Held: opens the menu, PLINE keeps running.
|
||||
let _ = app.update(Message::ViewportRightPress);
|
||||
{
|
||||
let mut sel = app.tabs[0].scene.selection.borrow_mut();
|
||||
sel.right_press_time = iced::time::Instant::now()
|
||||
.checked_sub(std::time::Duration::from_millis(400));
|
||||
}
|
||||
let _ = app.update(Message::ViewportRightRelease);
|
||||
assert!(menu_open(&app));
|
||||
assert_eq!(active(&app), Some("PLINE"));
|
||||
// Dismiss, then a quick click finishes the polyline.
|
||||
let _ = app.update(Message::CommandEscape);
|
||||
assert!(!menu_open(&app));
|
||||
assert_eq!(active(&app), Some("PLINE"));
|
||||
right_click(&mut app);
|
||||
assert!(!menu_open(&app));
|
||||
assert_eq!(active(&app), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_text_runs_as_enter_in_all_modes() {
|
||||
with_stack(|| {
|
||||
for mode in RightClickMode::ALL {
|
||||
let mut app = app();
|
||||
app.right_click_mode = mode;
|
||||
start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]);
|
||||
app.command_line.input = "C".into();
|
||||
right_click(&mut app);
|
||||
assert!(!menu_open(&app), "{mode:?}");
|
||||
assert_eq!(active(&app), None, "{mode:?}: Close should end LINE");
|
||||
let lines = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| matches!(e, acadrust::EntityType::Line(_)))
|
||||
.count();
|
||||
assert_eq!(lines, 3, "{mode:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_menu_pick_option_feeds_keyword() {
|
||||
with_stack(|| {
|
||||
let mut app = app();
|
||||
start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]);
|
||||
right_click(&mut app);
|
||||
assert!(menu_open(&app));
|
||||
let _ = app.update(Message::ContextMenuPick(MenuAction::Option("C".into())));
|
||||
assert!(!menu_open(&app));
|
||||
assert_eq!(active(&app), None);
|
||||
let closed = app.tabs[0].scene.document.entities().any(|e| {
|
||||
matches!(e, acadrust::EntityType::LwPolyline(p) if p.is_closed && p.vertices.len() == 3)
|
||||
});
|
||||
assert!(closed, "PLINE should have been closed by the menu pick");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_menu_pick_closes_menu_and_resets_enter_cycle() {
|
||||
with_stack(|| {
|
||||
let mut app = app();
|
||||
app.right_click_mode = RightClickMode::EnterFirst;
|
||||
start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]);
|
||||
right_click(&mut app); // Enter → LINE ends
|
||||
right_click(&mut app); // idle → menu
|
||||
assert!(menu_open(&app));
|
||||
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().right_click_entered);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_enter_with_no_highlight_picks_default() {
|
||||
with_stack(|| {
|
||||
let mut app = app();
|
||||
start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0)]);
|
||||
right_click(&mut app);
|
||||
let _ = app.update(Message::CommandFinalize);
|
||||
assert!(!menu_open(&app));
|
||||
assert_eq!(active(&app), None, "default row (Done) finishes PLINE");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_arrow_down_then_enter_picks_cancel() {
|
||||
with_stack(|| {
|
||||
let mut app = app();
|
||||
start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]);
|
||||
right_click(&mut app);
|
||||
let _ = app.update(Message::ContextMenuNavigate(ContextMenuNav::Down)); // Enter row
|
||||
assert_eq!(
|
||||
app.tabs[0].scene.selection.borrow().context_menu_ui.highlighted,
|
||||
Some(0)
|
||||
);
|
||||
let _ = app.update(Message::ContextMenuNavigate(ContextMenuNav::Down)); // Cancel
|
||||
let _ = app.update(Message::ContextMenuNavigate(ContextMenuNav::Enter));
|
||||
assert!(!menu_open(&app));
|
||||
assert_eq!(active(&app), None);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_mnemonic_c_closes_pline() {
|
||||
with_stack(|| {
|
||||
let mut app = app();
|
||||
start(&mut app, "PLINE", &[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]);
|
||||
right_click(&mut app);
|
||||
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, acadrust::EntityType::LwPolyline(p) if p.is_closed),
|
||||
);
|
||||
assert!(closed);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_unmatched_char_closes_menu_and_types() {
|
||||
with_stack(|| {
|
||||
let mut app = app();
|
||||
app.dyn_input = false;
|
||||
start(&mut app, "LINE", &[(0.0, 0.0)]);
|
||||
right_click(&mut app);
|
||||
assert!(menu_open(&app));
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_input_pick_refeeds_token() {
|
||||
with_stack(|| {
|
||||
let mut app = app();
|
||||
start(&mut app, "LINE", &[(0.0, 0.0)]);
|
||||
let _ = app.feed_active_cmd("10,10");
|
||||
assert_eq!(app.command_line.recent_inputs, vec!["10,10".to_string()]);
|
||||
let _ = app.feed_active_cmd("20,0");
|
||||
right_click(&mut app);
|
||||
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())));
|
||||
assert!(!menu_open(&app));
|
||||
let lines = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| matches!(e, acadrust::EntityType::Line(_)))
|
||||
.count();
|
||||
assert_eq!(lines, 3);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_selection_menu_erase_deletes_selection() {
|
||||
with_stack(|| {
|
||||
let mut app = app();
|
||||
start(&mut app, "LINE", &[(0.0, 0.0), (10.0, 0.0)]);
|
||||
let _ = app.update(Message::CommandEscape);
|
||||
let handle = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.find_map(|e| match e {
|
||||
acadrust::EntityType::Line(l) => Some(l.common.handle),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a line");
|
||||
app.tabs[0].scene.select_entities(&[handle]);
|
||||
right_click(&mut app);
|
||||
let acts = menu_actions(&app);
|
||||
assert!(acts.contains(&MenuAction::DeleteSelected));
|
||||
assert!(acts.contains(&MenuAction::Command("ROTATE".into())));
|
||||
let _ = app.update(Message::ContextMenuPick(MenuAction::DeleteSelected));
|
||||
assert!(!menu_open(&app));
|
||||
let lines = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| matches!(e, acadrust::EntityType::Line(_)))
|
||||
.count();
|
||||
assert_eq!(lines, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Grip menu ─────────────────────────────────────────────────────
|
||||
|
||||
/// A drawing with one line, selected, its start grip hot at the origin.
|
||||
fn grip_app() -> (OpenCADStudio, Handle) {
|
||||
use acadrust::{entities::Line, types::Vector3, EntityType};
|
||||
let mut app = app();
|
||||
app.snapper.snap_enabled = false;
|
||||
app.snapper.grid_snap_on = false;
|
||||
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),
|
||||
)));
|
||||
app.tabs[0].scene.selected.insert(handle);
|
||||
app.refresh_selected_grips();
|
||||
app.tabs[0].active_grip = Some(crate::scene::pick::grip::GripEdit::single(
|
||||
handle,
|
||||
0,
|
||||
false,
|
||||
DVec3::ZERO,
|
||||
));
|
||||
(app, handle)
|
||||
}
|
||||
|
||||
fn move_cursor_to(app: &mut OpenCADStudio, world: DVec3) {
|
||||
let cursor = app.tabs[0]
|
||||
.scene
|
||||
.camera
|
||||
.borrow()
|
||||
.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));
|
||||
}
|
||||
|
||||
fn line_start(app: &OpenCADStudio, handle: Handle) -> (f64, f64) {
|
||||
match app.tabs[0].scene.document.get_entity(handle) {
|
||||
Some(acadrust::EntityType::Line(l)) => (l.start.x, l.start.y),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rmb_on_hot_grip_opens_grip_menu() {
|
||||
with_stack(|| {
|
||||
let (mut app, _) = grip_app();
|
||||
right_click(&mut app);
|
||||
assert!(menu_open(&app));
|
||||
let acts = menu_actions(&app);
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grip_menu_move_dispatches_move_with_grip_base() {
|
||||
with_stack(|| {
|
||||
let (mut app, _) = grip_app();
|
||||
right_click(&mut app);
|
||||
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 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));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grip_menu_exit_restores_original_geometry() {
|
||||
with_stack(|| {
|
||||
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");
|
||||
right_click(&mut app);
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grip_menu_undo_snaps_back_but_stays_hot() {
|
||||
with_stack(|| {
|
||||
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 (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");
|
||||
assert_eq!(grip.last_world, grip.origin_world);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grip_menu_copy_leaves_original_and_adds_copy() {
|
||||
with_stack(|| {
|
||||
let (mut app, handle) = grip_app();
|
||||
right_click(&mut app);
|
||||
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);
|
||||
let lines: Vec<(f64, f64)> = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|e| match e {
|
||||
acadrust::EntityType::Line(l) => Some((l.start.x, l.start.y)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
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");
|
||||
// One undo step removes the copy again.
|
||||
app.undo_steps(1);
|
||||
let count = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| matches!(e, acadrust::EntityType::Line(_)))
|
||||
.count();
|
||||
assert_eq!(count, 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grip_menu_base_point_rebases_without_placing() {
|
||||
with_stack(|| {
|
||||
let (mut app, handle) = grip_app();
|
||||
right_click(&mut app);
|
||||
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);
|
||||
let _ = app.update(Message::ViewportLeftRelease);
|
||||
let grip = app.tabs[0].active_grip.as_ref().expect("grip still hot");
|
||||
assert_eq!(grip.origin_world, grip.last_world);
|
||||
assert!(!app.tabs[0].grip_base_pending);
|
||||
let (x, y) = line_start(&app, handle);
|
||||
assert!((x - 1.0).abs() < 1e-3 && (y - 1.0).abs() < 1e-3);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod transparent_tests {
|
||||
use super::*;
|
||||
use crate::command::StepInput;
|
||||
use glam::DVec3;
|
||||
|
||||
fn with_stack(f: impl FnOnce() + Send + 'static) {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(32 * 1024 * 1024)
|
||||
.spawn(f)
|
||||
.expect("spawn test thread")
|
||||
.join()
|
||||
.unwrap_or_else(|e| std::panic::resume_unwind(e));
|
||||
}
|
||||
|
||||
fn line_app() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0);
|
||||
let _ = app.dispatch_command("LINE");
|
||||
let _ = app.feed_command(StepInput::Point(DVec3::ZERO));
|
||||
app
|
||||
}
|
||||
|
||||
fn active(app: &OpenCADStudio) -> Option<&'static str> {
|
||||
app.tabs[0].active_cmd.as_ref().map(|c| c.name())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_pan_keeps_the_command_running() {
|
||||
with_stack(|| {
|
||||
let mut app = line_app();
|
||||
let _ = app.update(Message::ContextMenuPick(MenuAction::Command("'PAN".into())));
|
||||
assert!(app.tabs[0].pan_mode);
|
||||
assert_eq!(active(&app), Some("LINE"));
|
||||
// Esc leaves pan mode; the line is still being drawn.
|
||||
let _ = app.update(Message::CommandEscape);
|
||||
assert!(!app.tabs[0].pan_mode);
|
||||
assert_eq!(active(&app), Some("LINE"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_zoom_window_resumes_the_command() {
|
||||
with_stack(|| {
|
||||
let mut app = line_app();
|
||||
let _ = app.dispatch_command("'ZOOM W");
|
||||
assert_eq!(active(&app), Some("ZOOM WINDOW"));
|
||||
assert!(app.tabs[0].transparent_resume);
|
||||
let _ = app.feed_command(StepInput::Point(DVec3::new(-5.0, -5.0, 0.0)));
|
||||
let _ = app.feed_command(StepInput::Point(DVec3::new(5.0, 5.0, 0.0)));
|
||||
assert_eq!(active(&app), Some("LINE"), "LINE resumes after the zoom");
|
||||
assert!(!app.tabs[0].transparent_resume);
|
||||
assert!(app.tabs[0].suspended_cmd.is_none());
|
||||
// The line still has its first point: the next pick draws a segment.
|
||||
let _ = app.feed_command(StepInput::Point(DVec3::new(10.0, 0.0, 0.0)));
|
||||
let lines = app.tabs[0]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter(|e| matches!(e, acadrust::EntityType::Line(_)))
|
||||
.count();
|
||||
assert_eq!(lines, 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_prompt_handoff_and_escape_both_resume() {
|
||||
with_stack(|| {
|
||||
let mut app = line_app();
|
||||
let _ = app.dispatch_command("'ZOOM");
|
||||
let _ = app.feed_active_cmd("E"); // → ZOOM EXTENTS, one-shot
|
||||
assert_eq!(active(&app), Some("LINE"));
|
||||
let _ = app.dispatch_command("'ZOOM");
|
||||
assert_eq!(active(&app), Some("ZOOM WINDOW"));
|
||||
let _ = app.update(Message::CommandEscape);
|
||||
assert_eq!(active(&app), Some("LINE"));
|
||||
assert!(!app.tabs[0].transparent_resume);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_command_drops_a_parked_one() {
|
||||
with_stack(|| {
|
||||
let mut app = line_app();
|
||||
let _ = app.dispatch_command("'ZOOM");
|
||||
let _ = app.dispatch_command("CIRCLE");
|
||||
assert_eq!(active(&app), Some("CIRCLE"));
|
||||
assert!(app.tabs[0].suspended_cmd.is_none());
|
||||
let _ = app.update(Message::CommandEscape);
|
||||
assert_eq!(active(&app), None, "no stale LINE comes back");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_override_none_is_one_shot() {
|
||||
with_stack(|| {
|
||||
let mut app = line_app();
|
||||
app.snapper.snap_enabled = true;
|
||||
let _ = app.update(Message::ContextMenuPick(MenuAction::SnapOverrideNone));
|
||||
assert!(!app.snapper.snap_enabled);
|
||||
app.snapper.clear_override();
|
||||
assert!(app.snapper.snap_enabled);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -599,6 +599,12 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
self.show_block_palette = false;
|
||||
self.block_palette.placing = None;
|
||||
}
|
||||
PanelId::ExternalReferences => {
|
||||
self.show_external_references = false;
|
||||
}
|
||||
PanelId::Browser => {
|
||||
self.show_browser = false;
|
||||
}
|
||||
PanelId::Properties => {
|
||||
self.show_properties = false;
|
||||
self.ribbon.set_properties(false);
|
||||
@@ -635,6 +641,11 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::DragMove(point) => {
|
||||
// NOTE: xref column drags intentionally do NOT ride this
|
||||
// path: its points live in workspace space while the header
|
||||
// tracker reports header-local points, and mixing the two
|
||||
// produced a one-time jump plus a stuck drag. Column moves
|
||||
// arrive via Message::XrefColMove only.
|
||||
if self.dock_dragging.is_some() {
|
||||
let avail = self.tabs[self.active_tab].scene.selection.borrow().vp_size.1;
|
||||
let side = if point.x < self.win_size.0 * 0.5 {
|
||||
@@ -676,6 +687,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
self.dock_resizing = None;
|
||||
self.dock_drag_last = None;
|
||||
self.dock_drag_target = None;
|
||||
self.xref_col_drag = None;
|
||||
self.xref_col_last = None;
|
||||
self.xref_split_drag = false;
|
||||
iced::Task::none()
|
||||
}
|
||||
}
|
||||
@@ -692,6 +706,8 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
match id {
|
||||
PanelId::Properties => self.show_properties,
|
||||
PanelId::BlockPalette => self.show_block_palette,
|
||||
PanelId::ExternalReferences => self.show_external_references,
|
||||
PanelId::Browser => self.show_browser,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -757,6 +773,493 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the Reference Manager's entry list from the active drawing.
|
||||
/// Uses the tab's session sets (unloaded + stat baseline) so CLI and
|
||||
/// palette agree; writes fresh load-time mtimes back into the tab's stat
|
||||
/// cache (`Stale` detectable from the second refresh on). Records the
|
||||
/// tab's `edit_revision` so the palette auto-rescans on doc changes.
|
||||
pub(crate) fn refresh_xref_manager(&mut self) {
|
||||
let i = self.active_tab;
|
||||
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 tab_id = self.tabs[i].id;
|
||||
let revision = self.tabs[i].edit_revision;
|
||||
let (host_name, host_path) = match &self.tabs[i].current_path {
|
||||
Some(p) => (
|
||||
p.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
p.to_string_lossy().into_owned(),
|
||||
),
|
||||
None => (String::new(), String::new()),
|
||||
};
|
||||
let baselines = {
|
||||
let tab = &self.tabs[i];
|
||||
self.xref_manager.refresh(
|
||||
&tab.scene.document,
|
||||
&base_dir,
|
||||
tab.xref_unloaded.as_set(),
|
||||
&tab.xref_stat_cache.0,
|
||||
&host_name,
|
||||
&host_path,
|
||||
)
|
||||
};
|
||||
let tab = &mut self.tabs[i];
|
||||
for (key, mtime) in baselines {
|
||||
tab.xref_stat_cache.insert(key, mtime);
|
||||
}
|
||||
// A clean rescan clears the open-time missing notice.
|
||||
if !self
|
||||
.xref_manager
|
||||
.entries
|
||||
.iter()
|
||||
.any(|e| e.status == crate::io::xref_model::RefStatus::NotFound)
|
||||
{
|
||||
tab.xref_missing = 0;
|
||||
}
|
||||
self.xref_manager.source_tab_id = Some(tab_id);
|
||||
self.xref_manager.source_edit_revision = revision;
|
||||
}
|
||||
|
||||
/// Re-scan when the palette is open but showing another tab's drawing or
|
||||
/// the active drawing changed since the last scan (CLI/palette mutation,
|
||||
/// XOPEN-return edit, undo/redo — all bump `edit_revision`). Cheap:
|
||||
/// `collect_entries` stats files without re-parsing the host.
|
||||
pub(crate) fn refresh_xref_manager_if_stale(&mut self) {
|
||||
if !self.dock_panel_visible(crate::ui::dock::PanelId::ExternalReferences) {
|
||||
return;
|
||||
}
|
||||
let i = self.active_tab;
|
||||
if self.xref_manager.source_tab_id != Some(self.tabs[i].id)
|
||||
|| self.xref_manager.source_edit_revision != self.tabs[i].edit_revision
|
||||
{
|
||||
self.refresh_xref_manager();
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared post-mutation sequence for reference ops (CLI + palette):
|
||||
/// repopulate scene caches, mirror xref layers into the Layers panel,
|
||||
/// and mark the tab dirty. Both call sites use it so the 5-line
|
||||
/// sequence cannot drift.
|
||||
pub(crate) fn post_ref_op(&mut self, i: usize) {
|
||||
self.tabs[i].scene.populate_hatches_from_document();
|
||||
self.tabs[i].scene.populate_images_from_document();
|
||||
// Image/PDF unload is intentionally session-only (the file formats do
|
||||
// not persist an unloaded bit), so keep their source entities but
|
||||
// remove their decoded render models. A reload repopulates the cache
|
||||
// after clearing the corresponding session key.
|
||||
let hidden_images: Vec<acadrust::types::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.filter_map(|entity| match entity {
|
||||
acadrust::EntityType::RasterImage(image)
|
||||
if image.definition_handle.is_some_and(|key| self.tabs[i].xref_unloaded.is_unloaded(key.value())) =>
|
||||
{
|
||||
Some(entity.common().handle)
|
||||
}
|
||||
acadrust::EntityType::Underlay(underlay)
|
||||
if matches!(underlay.underlay_type, acadrust::entities::UnderlayType::Pdf)
|
||||
&& self.tabs[i].xref_unloaded.is_unloaded(underlay.definition_handle.value()) =>
|
||||
{
|
||||
Some(entity.common().handle)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for handle in hidden_images {
|
||||
self.tabs[i].scene.images.remove(&handle);
|
||||
}
|
||||
self.tabs[i].scene.populate_meshes_from_document();
|
||||
self.refresh_layer_panel();
|
||||
self.tabs[i].dirty = true;
|
||||
}
|
||||
|
||||
/// Execute a toolbar [`XrefPaletteOp`] on the palette's actionable
|
||||
/// selection. Same engine fns as the CLI arms, batched with per-item
|
||||
/// report lines in the CLI wording; finishes with [`post_ref_op`] plus
|
||||
/// a palette rescan (mirror of the CLI post-op sequence).
|
||||
pub(crate) fn xref_manager_op(
|
||||
&mut self,
|
||||
op: crate::ui::window::xref_manager::XrefPaletteOp,
|
||||
) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref());
|
||||
return;
|
||||
}
|
||||
use crate::ui::window::xref_manager::XrefPaletteOp;
|
||||
let i = self.active_tab;
|
||||
// Full selection (nested rows included) with per-entry metadata, so
|
||||
// nested rows report per-entry errors instead of being silently
|
||||
// dropped by `actionable_selection` (mirrors the CLI arms).
|
||||
let picked: Vec<(u64, String, crate::io::xref_model::RefKind, bool)> = {
|
||||
let mut idx: Vec<usize> = self.xref_manager.selected.iter().copied().collect();
|
||||
idx.sort_unstable();
|
||||
idx.iter()
|
||||
.filter_map(|idx| {
|
||||
self.xref_manager.entries.get(*idx).map(|e| {
|
||||
(
|
||||
e.key,
|
||||
e.name.clone(),
|
||||
e.kind,
|
||||
e.parent_key.is_some(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if picked.is_empty() {
|
||||
self.command_line
|
||||
.push_info(crate::t!("Select a reference first.").as_ref());
|
||||
return;
|
||||
}
|
||||
let needs_host = matches!(
|
||||
op,
|
||||
XrefPaletteOp::Reload | XrefPaletteOp::Bind | XrefPaletteOp::Pathtype(_)
|
||||
);
|
||||
let host: Option<std::path::PathBuf> = self.tabs[i].current_path.clone();
|
||||
if needs_host && host.is_none() {
|
||||
self.command_line
|
||||
.push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref());
|
||||
return;
|
||||
}
|
||||
let label = match op {
|
||||
XrefPaletteOp::Open => "XREF-OPEN",
|
||||
XrefPaletteOp::Detach => "XREF-DETACH",
|
||||
XrefPaletteOp::Unload => "XREF-UNLOAD",
|
||||
XrefPaletteOp::Reload => "XREF-RELOAD",
|
||||
XrefPaletteOp::Bind => "XREF-BIND",
|
||||
XrefPaletteOp::Overlay => "XREF-OVERLAY",
|
||||
XrefPaletteOp::Attach => "XREF-ATTACH",
|
||||
XrefPaletteOp::Pathtype(_) => "XREF-PATHTYPE",
|
||||
};
|
||||
self.push_undo_snapshot(i, label);
|
||||
let mut done = 0usize;
|
||||
match op {
|
||||
XrefPaletteOp::Detach => {
|
||||
for (key, name, _, is_nested) in &picked {
|
||||
if *is_nested {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF: cannot detach nested reference '{}'. Detach it in its host drawing.",
|
||||
name
|
||||
).as_ref());
|
||||
continue;
|
||||
}
|
||||
match crate::io::xref::detach_reference(
|
||||
&mut self.tabs[i].scene.document,
|
||||
*key,
|
||||
) {
|
||||
Ok(name) => {
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"XREF: detached \"{}\".",
|
||||
name
|
||||
).as_ref());
|
||||
self.tabs[i].xref_unloaded.remove(key);
|
||||
self.tabs[i].xref_stat_cache.remove(key);
|
||||
done += 1;
|
||||
}
|
||||
Err(msg) => self.command_line.push_error(msg.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
XrefPaletteOp::Unload => {
|
||||
for (key, name, _, is_nested) in &picked {
|
||||
if *is_nested {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF: cannot unload nested reference '{}'. Unload it in its host drawing.",
|
||||
name
|
||||
).as_ref());
|
||||
continue;
|
||||
}
|
||||
match crate::io::xref::unload_reference(
|
||||
&mut self.tabs[i].scene.document,
|
||||
*key,
|
||||
) {
|
||||
Ok(name) => {
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"XREF: unloaded \"{}\".",
|
||||
name
|
||||
).as_ref());
|
||||
self.tabs[i].xref_unloaded.add(*key);
|
||||
done += 1;
|
||||
}
|
||||
Err(msg) => self.command_line.push_error(msg.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
XrefPaletteOp::Reload => {
|
||||
let base_dir: std::path::PathBuf = host
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
// Drawing references only: image/PDF rows keep their flags
|
||||
// untouched and report per-entry (their names never match the
|
||||
// DWG-only `XrefInfo` list, so matching after the flag clear
|
||||
// would spuriously report no-match).
|
||||
let dwg_picked: Vec<(u64, String)> = picked
|
||||
.iter()
|
||||
.filter(|(_, _, kind, is_nested)| {
|
||||
if *is_nested {
|
||||
return false;
|
||||
}
|
||||
*kind == crate::io::xref_model::RefKind::DwgXref
|
||||
})
|
||||
.map(|(key, name, _, _)| (*key, name.clone()))
|
||||
.collect();
|
||||
for (_, name, kind, is_nested) in &picked {
|
||||
if *is_nested {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF: cannot reload nested reference '{}'. Reload it in its host drawing.",
|
||||
name
|
||||
).as_ref());
|
||||
} else if *kind != crate::io::xref_model::RefKind::DwgXref {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"{}: reload applies to drawing references only.",
|
||||
name
|
||||
).as_ref());
|
||||
}
|
||||
}
|
||||
for (key, _) in &dwg_picked {
|
||||
self.tabs[i].xref_unloaded.remove(key);
|
||||
self.tabs[i].xref_stat_cache.remove(key);
|
||||
}
|
||||
let handles: rustc_hash::FxHashSet<acadrust::types::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.filter(|br| dwg_picked.iter().any(|(key, _)| *key == br.handle.value()))
|
||||
.map(|br| br.handle)
|
||||
.collect();
|
||||
let (infos, _dropped) = crate::io::xref::resolve_xrefs_for_keys(
|
||||
&mut self.tabs[i].scene.document,
|
||||
&base_dir,
|
||||
&handles,
|
||||
);
|
||||
// Targeted reload updates baselines only for selected rows.
|
||||
{
|
||||
let fresh = crate::io::xref::collect_entries_with_prev(
|
||||
&self.tabs[i].scene.document,
|
||||
&base_dir,
|
||||
self.tabs[i].xref_unloaded.as_set(),
|
||||
&self.tabs[i].xref_stat_cache.0,
|
||||
);
|
||||
for e in &fresh {
|
||||
if !dwg_picked.iter().any(|(key, _)| *key == e.key) {
|
||||
continue;
|
||||
}
|
||||
if e.status == crate::io::xref_model::RefStatus::Loaded {
|
||||
if let Some(m) = e.modified {
|
||||
self.tabs[i].xref_stat_cache.insert(e.key, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, name) in &dwg_picked {
|
||||
match infos
|
||||
.iter()
|
||||
.find(|n| n.name.eq_ignore_ascii_case(name))
|
||||
{
|
||||
Some(info) => {
|
||||
self.report_xref_status(info);
|
||||
done += 1;
|
||||
}
|
||||
None => self.command_line.push_error(crate::tf!(
|
||||
"XREF: no references match '{}'.",
|
||||
name
|
||||
).as_ref()),
|
||||
}
|
||||
}
|
||||
}
|
||||
XrefPaletteOp::Overlay => {
|
||||
for (key, name, _, is_nested) in &picked {
|
||||
if *is_nested {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF: cannot overlay nested reference '{}'. Overlay it in its host drawing.",
|
||||
name
|
||||
).as_ref());
|
||||
continue;
|
||||
}
|
||||
match crate::io::xref::set_ref_type(
|
||||
&mut self.tabs[i].scene.document,
|
||||
*key,
|
||||
crate::io::xref_model::RefType::Overlay,
|
||||
) {
|
||||
Ok(name) => {
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"XREF: \"{}\" set to Overlay.",
|
||||
name
|
||||
).as_ref());
|
||||
done += 1;
|
||||
}
|
||||
Err(msg) => self.command_line.push_error(msg.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
XrefPaletteOp::Attach => {
|
||||
for (key, name, _, is_nested) in &picked {
|
||||
if *is_nested {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF: cannot attach nested reference '{}'. Attach it in its host drawing.",
|
||||
name
|
||||
).as_ref());
|
||||
continue;
|
||||
}
|
||||
match crate::io::xref::set_ref_type(
|
||||
&mut self.tabs[i].scene.document,
|
||||
*key,
|
||||
crate::io::xref_model::RefType::Attach,
|
||||
) {
|
||||
Ok(name) => {
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"XREF: \"{}\" set to Attach.",
|
||||
name
|
||||
).as_ref());
|
||||
done += 1;
|
||||
}
|
||||
Err(msg) => self.command_line.push_error(msg.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
XrefPaletteOp::Bind => {
|
||||
let base_dir: std::path::PathBuf = host
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
let host_dir: std::path::PathBuf = host
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent().map(|p| p.to_path_buf()))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
for (key, name, _, is_nested) in &picked {
|
||||
if *is_nested {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF: cannot bind nested reference '{}'. Bind it in its host drawing.",
|
||||
name
|
||||
).as_ref());
|
||||
continue;
|
||||
}
|
||||
match crate::io::xref::bind_reference(
|
||||
&mut self.tabs[i].scene.document,
|
||||
*key,
|
||||
&base_dir,
|
||||
&host_dir,
|
||||
) {
|
||||
Ok(outcome) => {
|
||||
if outcome.unremapped == 0 {
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"XREF: bound \"{}\".",
|
||||
outcome.name
|
||||
).as_ref());
|
||||
} else {
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"XREF: bound \"{}\" with {} unremapped style handles (see bind limitations).",
|
||||
outcome.name, outcome.unremapped
|
||||
).as_ref());
|
||||
}
|
||||
self.tabs[i].xref_unloaded.remove(key);
|
||||
self.tabs[i].xref_stat_cache.remove(key);
|
||||
done += 1;
|
||||
}
|
||||
Err(msg) => self.command_line.push_error(msg.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
XrefPaletteOp::Pathtype(pathtype) => {
|
||||
let host = host.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
for (key, name, _, is_nested) in &picked {
|
||||
if *is_nested {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF: cannot set path type for nested reference '{}'. Set it in its host drawing.",
|
||||
name
|
||||
).as_ref());
|
||||
continue;
|
||||
}
|
||||
match crate::io::xref::apply_pathtype(
|
||||
&mut self.tabs[i].scene.document,
|
||||
*key,
|
||||
pathtype,
|
||||
&host,
|
||||
) {
|
||||
Ok(_) => {
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"XREF: Path set for \"{}\" — Reload to apply.",
|
||||
name
|
||||
).as_ref());
|
||||
done += 1;
|
||||
}
|
||||
Err(msg) => self.command_line.push_error(msg.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
XrefPaletteOp::Open => {
|
||||
// Handled in update/mod.rs XrefRowOp dispatch (navigation), not here.
|
||||
}
|
||||
}
|
||||
if done > 0 {
|
||||
self.post_ref_op(i);
|
||||
}
|
||||
self.refresh_xref_manager();
|
||||
}
|
||||
|
||||
/// Reload every direct drawing reference (toolbar Reload All). Same
|
||||
/// engine path as `XRELOAD`: undo snapshot, session flags cleared,
|
||||
/// full resolve, per-ref report, stat baselines refreshed.
|
||||
pub(crate) fn xref_manager_reload_all(&mut self) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
self.command_line.push_error(crate::t!("Reference changes are not available on web — the reference list is read-only.").as_ref());
|
||||
return;
|
||||
}
|
||||
let i = self.active_tab;
|
||||
let Some(path) = self.tabs[i].current_path.clone() else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref());
|
||||
return;
|
||||
};
|
||||
let Some(base_dir) = path.parent().map(|p| p.to_path_buf()) else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref());
|
||||
return;
|
||||
};
|
||||
let reload_keys: Vec<u64> = crate::io::xref::collect_entries_with_prev(
|
||||
&self.tabs[i].scene.document,
|
||||
&base_dir,
|
||||
self.tabs[i].xref_unloaded.as_set(),
|
||||
&self.tabs[i].xref_stat_cache.0,
|
||||
)
|
||||
.iter()
|
||||
.filter(|e| e.kind == crate::io::xref_model::RefKind::DwgXref && e.parent_key.is_none())
|
||||
.map(|e| e.key)
|
||||
.collect();
|
||||
self.push_undo_snapshot(i, "XREF-RELOAD");
|
||||
for key in &reload_keys {
|
||||
self.tabs[i].xref_unloaded.remove(key);
|
||||
self.tabs[i].xref_stat_cache.remove(key);
|
||||
}
|
||||
let (infos, _dropped) =
|
||||
crate::io::xref::resolve_xrefs(&mut self.tabs[i].scene.document, &base_dir);
|
||||
let fresh = crate::io::xref::collect_entries_with_prev(
|
||||
&self.tabs[i].scene.document,
|
||||
&base_dir,
|
||||
self.tabs[i].xref_unloaded.as_set(),
|
||||
&self.tabs[i].xref_stat_cache.0,
|
||||
);
|
||||
for e in &fresh {
|
||||
if e.status == crate::io::xref_model::RefStatus::Loaded {
|
||||
if let Some(m) = e.modified {
|
||||
self.tabs[i].xref_stat_cache.insert(e.key, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
for info in &infos {
|
||||
self.report_xref_status(info);
|
||||
}
|
||||
self.post_ref_op(i);
|
||||
self.refresh_xref_manager();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1254,4 +1757,310 @@ mod tests {
|
||||
assert_eq!(app.block_name_from_file("Chair"), "Chair (2)");
|
||||
assert_eq!(app.block_name_from_file("Table"), "Table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_rebases_live_doc_paths() {
|
||||
// F4: on Save-As completion the live document's relative reference
|
||||
// paths are rebased onto the new base dir (same helper the save
|
||||
// snapshot used), so the session agrees with the file just written.
|
||||
let mut app = fresh();
|
||||
let i = app.active_tab;
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ocs_saveas_rebase_{}_{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let old_dir = dir.join("old");
|
||||
let new_dir = dir.join("new");
|
||||
std::fs::create_dir_all(&old_dir).unwrap();
|
||||
std::fs::create_dir_all(&new_dir).unwrap();
|
||||
let mut br = acadrust::tables::BlockRecord::new("PLAN");
|
||||
br.flags.is_xref = true;
|
||||
br.xref_path = "refs/plan.dwg".to_string();
|
||||
br.handle = app.tabs[i].scene.document.allocate_handle();
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.add(br)
|
||||
.unwrap();
|
||||
app.tabs[i].current_path = Some(old_dir.join("host.dwg"));
|
||||
let tab_id = app.tabs[i].id;
|
||||
let job_id = 4242u64;
|
||||
app.active_save_jobs.insert(tab_id, job_id);
|
||||
let outcome = crate::app::SaveOutcome {
|
||||
job_id,
|
||||
tab_id,
|
||||
epoch: app.tabs[i].scene.geometry_epoch,
|
||||
revision: app.tabs[i].edit_revision,
|
||||
camera_generation: app.tabs[i].scene.camera_generation,
|
||||
path: new_dir.join("host.dwg"),
|
||||
version: app.tabs[i].scene.document.version,
|
||||
previous_autosave: None,
|
||||
set_current_path: true,
|
||||
purpose: crate::app::SavePurpose::SaveAs,
|
||||
continuation: crate::app::SaveContinuation::None,
|
||||
refreshed_preview: None,
|
||||
result: Ok(()),
|
||||
};
|
||||
let _ = app.on_save_finished(outcome);
|
||||
assert_eq!(
|
||||
app.tabs[i].current_path.as_deref(),
|
||||
Some(new_dir.join("host.dwg").as_path())
|
||||
);
|
||||
let br = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get("PLAN")
|
||||
.unwrap();
|
||||
assert_eq!(br.xref_path, "../old/refs/plan.dwg");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
fn palette_tmpdir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ocs_palette_{tag}_{}_{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn palette_output(app: &OpenCADStudio, start: usize) -> String {
|
||||
app.command_line.history[start..]
|
||||
.iter()
|
||||
.map(|e| e.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_rowop_nested_reports_per_entry() {
|
||||
// Behavioral matrix: every row op on a NESTED row via the GUI
|
||||
// message path. Nested rows must report per-entry errors (CLI
|
||||
// wording), never silently skip.
|
||||
use acadrust::tables::BlockRecord;
|
||||
use crate::app::Message;
|
||||
use crate::ui::window::xref_manager::XrefPaletteOp;
|
||||
let dir = palette_tmpdir("nestedmatrix");
|
||||
let mut host_doc = acadrust::CadDocument::new();
|
||||
let mut inner = BlockRecord::new("INNER");
|
||||
inner.flags.is_xref = true;
|
||||
inner.xref_path = "inner.dwg".to_string();
|
||||
host_doc.block_records.add(inner).unwrap();
|
||||
let bytes = crate::io::save_to_bytes(&host_doc, "dwg", host_doc.version).unwrap();
|
||||
std::fs::write(dir.join("host.dwg"), &bytes).unwrap();
|
||||
let mut app = fresh();
|
||||
let i = app.active_tab;
|
||||
let mut br = BlockRecord::new("HOST");
|
||||
br.flags.is_xref = true;
|
||||
br.xref_path = dir.join("host.dwg").to_string_lossy().into_owned();
|
||||
br.handle = app.tabs[i].scene.document.allocate_handle();
|
||||
app.tabs[i].scene.document.block_records.add(br).unwrap();
|
||||
app.tabs[i].current_path = Some(dir.join("app.dwg"));
|
||||
app.refresh_xref_manager();
|
||||
let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed");
|
||||
assert!(app.xref_manager.entries[idx].parent_key.is_some());
|
||||
for op in [
|
||||
XrefPaletteOp::Detach,
|
||||
XrefPaletteOp::Unload,
|
||||
XrefPaletteOp::Reload,
|
||||
XrefPaletteOp::Bind,
|
||||
XrefPaletteOp::Overlay,
|
||||
] {
|
||||
let start = app.command_line.history.len();
|
||||
let _ = app.update(Message::XrefRowOp(idx, op));
|
||||
let out = palette_output(&app, start);
|
||||
assert!(out.contains("INNER"), "op {op:?} on nested row gave: {out:?}");
|
||||
assert!(
|
||||
app.tabs[i].scene.document.block_records.get("HOST").is_some(),
|
||||
"op {op:?} must not mutate the direct reference"
|
||||
);
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_detach_unload_rowop_gui_path() {
|
||||
// Reproduction for "Detach/Unload from the palette do nothing":
|
||||
// drive the exact GUI message path (row right-click menu item).
|
||||
use acadrust::tables::BlockRecord;
|
||||
use crate::app::Message;
|
||||
use crate::ui::window::xref_manager::XrefPaletteOp;
|
||||
let mut app = fresh();
|
||||
let i = app.active_tab;
|
||||
for name in ["PLAN", "SITE"] {
|
||||
let mut br = BlockRecord::new(name);
|
||||
br.flags.is_xref = true;
|
||||
br.xref_path = format!("old/{}.dwg", name.to_lowercase());
|
||||
br.handle = app.tabs[i].scene.document.allocate_handle();
|
||||
app.tabs[i].scene.document.block_records.add(br).unwrap();
|
||||
}
|
||||
app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_repro_host.dwg"));
|
||||
app.refresh_xref_manager();
|
||||
let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed");
|
||||
let start = app.command_line.history.len();
|
||||
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Detach));
|
||||
let out = palette_output(&app, start);
|
||||
assert!(out.contains("PLAN"), "detach output missing, got: {out:?}");
|
||||
assert!(app.tabs[i].scene.document.block_records.get("PLAN").is_none(), "PLAN definition must be gone");
|
||||
let idx = app.xref_manager.entries.iter().position(|e| e.name == "SITE").expect("SITE listed");
|
||||
let start = app.command_line.history.len();
|
||||
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Unload));
|
||||
let out = palette_output(&app, start);
|
||||
assert!(out.contains("SITE"), "unload output missing, got: {out:?}");
|
||||
assert_eq!(
|
||||
app.xref_manager.entries.iter().find(|e| e.name == "SITE").unwrap().status,
|
||||
crate::io::xref_model::RefStatus::Unloaded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_overlay_and_pathtype_rowop_gui_path() {
|
||||
// Row-menu Overlay + Change-Path row ops on a direct row via the
|
||||
// GUI message path: type flag flips, saved path clears.
|
||||
use acadrust::tables::BlockRecord;
|
||||
use crate::app::Message;
|
||||
use crate::io::xref_model::{Pathtype, RefType};
|
||||
use crate::ui::window::xref_manager::XrefPaletteOp;
|
||||
let mut app = fresh();
|
||||
let i = app.active_tab;
|
||||
let mut br = BlockRecord::new("PLAN");
|
||||
br.flags.is_xref = true;
|
||||
br.xref_path = "old/plan.dwg".to_string();
|
||||
br.handle = app.tabs[i].scene.document.allocate_handle();
|
||||
app.tabs[i].scene.document.block_records.add(br).unwrap();
|
||||
app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_rowop_host.dwg"));
|
||||
app.refresh_xref_manager();
|
||||
let idx = app.xref_manager.entries.iter().position(|e| e.name == "PLAN").expect("PLAN listed");
|
||||
let start = app.command_line.history.len();
|
||||
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Overlay));
|
||||
let out = palette_output(&app, start);
|
||||
assert!(out.contains("PLAN"), "got: {out:?}");
|
||||
let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap();
|
||||
assert!(br.flags.is_xref_overlay && !br.flags.is_xref);
|
||||
assert_eq!(app.xref_manager.entries.iter().find(|e| e.name == "PLAN").unwrap().ref_type, RefType::Overlay);
|
||||
let start = app.command_line.history.len();
|
||||
let _ = app.update(Message::XrefRowOp(idx, XrefPaletteOp::Pathtype(Pathtype::None)));
|
||||
let out = palette_output(&app, start);
|
||||
assert!(out.contains("PLAN"), "got: {out:?}");
|
||||
let br = app.tabs[i].scene.document.block_records.get("PLAN").unwrap();
|
||||
assert_eq!(br.xref_path, "plan.dwg", "Remove Path strips to the bare filename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_reload_all_resolves_direct_refs() {
|
||||
// Toolbar Reload All against a real on-disk reference: full
|
||||
// resolve, per-ref report, entry back to Loaded.
|
||||
use acadrust::tables::BlockRecord;
|
||||
let dir = palette_tmpdir("reloadall");
|
||||
let ref_doc = acadrust::CadDocument::new();
|
||||
let bytes = crate::io::save_to_bytes(&ref_doc, "dwg", ref_doc.version).unwrap();
|
||||
std::fs::write(dir.join("plan.dwg"), &bytes).unwrap();
|
||||
let mut app = fresh();
|
||||
let i = app.active_tab;
|
||||
let mut br = BlockRecord::new("PLAN");
|
||||
br.flags.is_xref = true;
|
||||
br.xref_path = dir.join("plan.dwg").to_string_lossy().into_owned();
|
||||
br.handle = app.tabs[i].scene.document.allocate_handle();
|
||||
app.tabs[i].scene.document.block_records.add(br).unwrap();
|
||||
app.tabs[i].current_path = Some(dir.join("host.dwg"));
|
||||
let start = app.command_line.history.len();
|
||||
app.xref_manager_reload_all();
|
||||
let out = palette_output(&app, start);
|
||||
assert!(out.contains("PLAN"), "reload-all must report the reference, got: {out:?}");
|
||||
let entry = app.xref_manager.entries.iter().find(|e| e.name == "PLAN").expect("PLAN listed");
|
||||
assert_eq!(entry.status, crate::io::xref_model::RefStatus::Loaded, "PLAN must resolve Loaded");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_replace_prompt_prefills_command_line() {
|
||||
// Toolbar Find and Replace hands the existing `XREF Path Find`
|
||||
// parsing a prefilled command line.
|
||||
use crate::app::Message;
|
||||
let mut app = fresh();
|
||||
let _ = app.update(Message::XrefFindReplacePrompt);
|
||||
assert_eq!(app.command_line.input, "XREF Path Find ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_unload_nested_reports_per_entry() {
|
||||
// F6: palette Unload on a nested row reports the per-entry nested
|
||||
// error (CLI wording) instead of the confusing 'no loaded reference'.
|
||||
use acadrust::tables::BlockRecord;
|
||||
use crate::ui::window::xref_manager::XrefPaletteOp;
|
||||
let dir = palette_tmpdir("nested");
|
||||
let mut host_doc = acadrust::CadDocument::new();
|
||||
let mut inner = BlockRecord::new("INNER");
|
||||
inner.flags.is_xref = true;
|
||||
inner.xref_path = "inner.dwg".to_string();
|
||||
host_doc.block_records.add(inner).unwrap();
|
||||
let bytes = crate::io::save_to_bytes(&host_doc, "dwg", host_doc.version).unwrap();
|
||||
std::fs::write(dir.join("host.dwg"), &bytes).unwrap();
|
||||
let mut app = fresh();
|
||||
let i = app.active_tab;
|
||||
let mut br = BlockRecord::new("HOST");
|
||||
br.flags.is_xref = true;
|
||||
br.xref_path = dir.join("host.dwg").to_string_lossy().into_owned();
|
||||
br.handle = app.tabs[i].scene.document.allocate_handle();
|
||||
app.tabs[i].scene.document.block_records.add(br).unwrap();
|
||||
app.tabs[i].current_path = Some(dir.join("app.dwg"));
|
||||
app.refresh_xref_manager();
|
||||
let idx = app.xref_manager.entries.iter().position(|e| e.name == "INNER").expect("nested INNER listed");
|
||||
assert!(app.xref_manager.entries[idx].parent_key.is_some());
|
||||
app.xref_manager.selected.insert(idx);
|
||||
let start = app.command_line.history.len();
|
||||
app.xref_manager_op(XrefPaletteOp::Unload);
|
||||
let out = palette_output(&app, start);
|
||||
assert!(out.contains("INNER"), "got: {out:?}");
|
||||
assert_eq!(app.command_line.history.len(), start + 1);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_reload_skips_images_without_clearing_flags() {
|
||||
// F7: palette Reload on an image/PDF row reports the drawing-only
|
||||
// error and leaves its unloaded flag untouched (no spurious
|
||||
// no-match after a flag clear).
|
||||
use acadrust::objects::{ImageDefinition, ObjectType};
|
||||
use crate::io::xref_model::RefKind;
|
||||
use crate::ui::window::xref_manager::XrefPaletteOp;
|
||||
let dir = palette_tmpdir("imgreload");
|
||||
let mut app = fresh();
|
||||
let i = app.active_tab;
|
||||
let h = app.tabs[i].scene.document.allocate_handle();
|
||||
let mut def = ImageDefinition::with_dimensions("img.png", 8, 8);
|
||||
def.handle = h;
|
||||
app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def));
|
||||
let mut img = acadrust::entities::RasterImage::new(
|
||||
"img.png",
|
||||
acadrust::types::Vector3::ZERO,
|
||||
8.0,
|
||||
8.0,
|
||||
);
|
||||
img.definition_handle = Some(h);
|
||||
app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap();
|
||||
app.tabs[i].current_path = Some(dir.join("host.dwg"));
|
||||
app.refresh_xref_manager();
|
||||
let idx = app.xref_manager.entries.iter().position(|e| e.kind == RefKind::Image).expect("image listed");
|
||||
let key = app.xref_manager.entries[idx].key;
|
||||
app.xref_manager.selected.insert(idx);
|
||||
app.tabs[i].xref_unloaded.add(key);
|
||||
let start = app.command_line.history.len();
|
||||
app.xref_manager_op(XrefPaletteOp::Reload);
|
||||
let out = palette_output(&app, start);
|
||||
assert!(out.contains("img.png"), "got: {out:?}");
|
||||
assert_eq!(app.command_line.history.len(), start + 1);
|
||||
assert!(app.tabs[i].xref_unloaded.is_unloaded(key), "image flag must stay untouched");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,10 @@ use acadrust::{EntityType as AcadEntityType, Handle};
|
||||
use iced::time::Instant;
|
||||
use iced::{mouse, Point, Task};
|
||||
|
||||
fn eval_dynamic_angle(text: &str) -> Option<f64> {
|
||||
let value = crate::app::expr_eval::eval_number(&text.trim().replace(',', "."))?;
|
||||
crate::entities::common::parse_angle(&value.to_string())
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(in crate::app) fn active_distance_ray(
|
||||
@@ -402,9 +406,9 @@ impl OpenCADStudio {
|
||||
let explicit_sign = raw
|
||||
.as_deref()
|
||||
.is_some_and(|s| s.starts_with('-') || s.starts_with('+'));
|
||||
match raw.and_then(|s| crate::app::expr_eval::eval_number(&s)) {
|
||||
Some(v) if explicit_sign => v.to_radians(),
|
||||
Some(mag) => mag.abs().to_radians().copysign(dy),
|
||||
match raw.as_deref().and_then(eval_dynamic_angle) {
|
||||
Some(v) if explicit_sign => v,
|
||||
Some(mag) => mag.abs().copysign(dy),
|
||||
None => live_a,
|
||||
}
|
||||
};
|
||||
@@ -474,7 +478,11 @@ impl OpenCADStudio {
|
||||
// absolute CCW angle in the UCS plane, not a cursor-signed
|
||||
// magnitude — keep it literal. Only the polar Distance+Angle
|
||||
// pair uses the cursor-signed `angle_rad`.
|
||||
let a = val(0, live_a.to_degrees()).to_radians();
|
||||
let a = fields[0]
|
||||
.buffer
|
||||
.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)))
|
||||
}
|
||||
_ => None,
|
||||
@@ -794,3 +802,33 @@ impl OpenCADStudio {
|
||||
self.tabs[i].scene.set_preview_wires(previews);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::eval_dynamic_angle;
|
||||
use crate::entities::common::{set_unit_context, unit_context};
|
||||
|
||||
#[test]
|
||||
fn dynamic_angle_expressions_follow_drawing_units() {
|
||||
let previous = unit_context();
|
||||
let mut context = previous;
|
||||
|
||||
context.aunits = 2;
|
||||
set_unit_context(context);
|
||||
assert!(
|
||||
(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()
|
||||
< 1e-12
|
||||
);
|
||||
|
||||
set_unit_context(previous);
|
||||
}
|
||||
}
|
||||
+2063
-326
File diff suppressed because it is too large.
Load diff
+861
-45
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,347 @@
|
||||
//! Importing named page setups from another drawing (`PSETUPIN`): the
|
||||
//! "Import…" chooser of the Plot dialog and the command that drives the same
|
||||
//! path from the command line.
|
||||
|
||||
use crate::app::{Message, OpenCADStudio};
|
||||
use crate::ui::window::plot::{PageSetupImportDraft, PageSetupImportMsg, PlotDlgMsg};
|
||||
use iced::Task;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Read the named page setups of the drawing at `path` off the UI thread.
|
||||
/// The result names the file the way the chooser shows it.
|
||||
fn read_page_setups_task(path: PathBuf) -> Task<Message> {
|
||||
super::file::background_task(
|
||||
move || {
|
||||
let file = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
crate::io::load_file(&path)
|
||||
.map(|document| (file, crate::scene::document_page_setups(&document)))
|
||||
},
|
||||
|result| Message::PlotDlg(PlotDlgMsg::Import(PageSetupImportMsg::Loaded(result))),
|
||||
)
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn on_page_setup_import(&mut self, msg: PageSetupImportMsg) -> Task<Message> {
|
||||
match msg {
|
||||
PageSetupImportMsg::Pick => Task::perform(
|
||||
async {
|
||||
let handle = rfd::AsyncFileDialog::new()
|
||||
.set_title(crate::t!("Select Drawing to Import Page Setups From").as_ref())
|
||||
.add_filter(
|
||||
crate::t!("Drawings and templates").as_ref(),
|
||||
&["dwg", "dxf", "dwt", "DWG", "DXF", "DWT"],
|
||||
)
|
||||
.pick_file()
|
||||
.await;
|
||||
handle.map(|h| crate::sys::handle_path(&h))
|
||||
},
|
||||
|picked| match picked {
|
||||
Some(path) => Message::PageSetupImportFile(path),
|
||||
None => Message::Noop,
|
||||
},
|
||||
),
|
||||
PageSetupImportMsg::Loaded(result) => {
|
||||
self.plot_dialog.import_draft = Some(match result {
|
||||
Ok((file, setups)) => PageSetupImportDraft {
|
||||
file,
|
||||
setups: setups.into_iter().map(|(name, ps)| (name, ps, true)).collect(),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => PageSetupImportDraft {
|
||||
file: String::new(),
|
||||
setups: Vec::new(),
|
||||
error: Some(error),
|
||||
},
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
PageSetupImportMsg::Toggle(name) => {
|
||||
if let Some(draft) = self.plot_dialog.import_draft.as_mut() {
|
||||
if let Some(entry) = draft.setups.iter_mut().find(|(n, _, _)| *n == name) {
|
||||
entry.2 = !entry.2;
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
PageSetupImportMsg::All(on) => {
|
||||
if let Some(draft) = self.plot_dialog.import_draft.as_mut() {
|
||||
for entry in &mut draft.setups {
|
||||
entry.2 = on;
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
PageSetupImportMsg::Apply => {
|
||||
let Some(draft) = self.plot_dialog.import_draft.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
let chosen: Vec<(String, acadrust::objects::PlotSettings)> = draft
|
||||
.selected()
|
||||
.map(|(name, ps)| (name.to_string(), ps.clone()))
|
||||
.collect();
|
||||
let imported = self.import_page_setups(chosen);
|
||||
if let Some(first) = imported.first() {
|
||||
let first = first.clone();
|
||||
self.select_page_setup(&first);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
PageSetupImportMsg::Cancel => {
|
||||
self.plot_dialog.import_draft = None;
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing was picked for import: read it in the background; the
|
||||
/// chooser appears in the Plot dialog once it is read.
|
||||
pub(super) fn on_page_setup_import_file(&mut self, path: PathBuf) -> Task<Message> {
|
||||
// The chooser lives in the Plot dialog; open it if the import was
|
||||
// started from the command line.
|
||||
let open = if self.active_modal == Some(crate::app::ModalKind::Plot) {
|
||||
Task::none()
|
||||
} else {
|
||||
self.on_plot_dialog_open()
|
||||
};
|
||||
Task::batch([open, read_page_setups_task(path)])
|
||||
}
|
||||
|
||||
/// Bring the given setups into the drawing, replacing same-named ones,
|
||||
/// as one undoable step. Returns the names imported.
|
||||
pub(super) fn import_page_setups(
|
||||
&mut self,
|
||||
setups: Vec<(String, acadrust::objects::PlotSettings)>,
|
||||
) -> Vec<String> {
|
||||
if setups.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let i = self.active_tab;
|
||||
self.push_undo_snapshot(i, "PSETUPIN");
|
||||
let mut replaced = 0usize;
|
||||
let existing = self.tabs[i].scene.page_setup_names();
|
||||
let mut names = Vec::with_capacity(setups.len());
|
||||
for (name, ps) in setups {
|
||||
if existing.iter().any(|n| n.eq_ignore_ascii_case(&name)) {
|
||||
replaced += 1;
|
||||
}
|
||||
self.tabs[i].scene.import_page_setup(&name, ps);
|
||||
names.push(name);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_page_setups();
|
||||
let count = names.len();
|
||||
self.command_line.push_output(
|
||||
crate::tf!("PSETUPIN: {count} page setup(s) imported, {replaced} replaced.").as_ref(),
|
||||
);
|
||||
names
|
||||
}
|
||||
|
||||
/// `PSETUPIN` / `-PSETUPIN`: with no file, ask for one and offer its
|
||||
/// setups in the Plot dialog; with `<file> *` or `<file> name,name`
|
||||
/// import those straight away; with just `<file>` open the chooser on it.
|
||||
pub(in crate::app) fn on_psetupin(&mut self, args: &str) -> Task<Message> {
|
||||
let args = args.trim();
|
||||
if args.is_empty() {
|
||||
return self.on_page_setup_import(PageSetupImportMsg::Pick);
|
||||
}
|
||||
// The file may carry spaces; the setup list, when given, is the last
|
||||
// word (`*` or a comma-separated list without spaces), so the
|
||||
// arguments are a file followed by names only when what precedes the
|
||||
// last word is itself a file.
|
||||
let (file, names) = match args.rsplit_once(char::is_whitespace) {
|
||||
Some((file, names)) if Path::new(file.trim()).is_file() => {
|
||||
(file.trim(), Some(names.trim()))
|
||||
}
|
||||
_ => (args, None),
|
||||
};
|
||||
let path = PathBuf::from(file);
|
||||
let Some(names) = names else {
|
||||
return self.on_page_setup_import_file(path);
|
||||
};
|
||||
let available = match crate::io::load_file(&path) {
|
||||
Ok(document) => crate::scene::document_page_setups(&document),
|
||||
Err(error) => {
|
||||
self.command_line
|
||||
.push_error(crate::tf!("PSETUPIN: {error}").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let chosen: Vec<(String, acadrust::objects::PlotSettings)> = if names == "*" {
|
||||
available
|
||||
} else {
|
||||
let wanted: Vec<&str> = names.split(',').map(str::trim).collect();
|
||||
let mut chosen = Vec::new();
|
||||
for want in wanted {
|
||||
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(),
|
||||
),
|
||||
}
|
||||
}
|
||||
chosen
|
||||
};
|
||||
if chosen.is_empty() {
|
||||
self.command_line
|
||||
.push_info(crate::tf!("PSETUPIN: nothing to import from {file}.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
self.import_page_setups(chosen);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use crate::app::{Message, OpenCADStudio};
|
||||
use crate::ui::window::plot::{PageSetupImportMsg as I, PlotDlgMsg};
|
||||
|
||||
const FIXTURE: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/plot/page-setups-metric.dxf"
|
||||
);
|
||||
|
||||
fn app() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let _ = app.update(Message::LayoutSwitch("Layout1".into()));
|
||||
app
|
||||
}
|
||||
|
||||
fn last_line(app: &OpenCADStudio) -> String {
|
||||
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
|
||||
/// through the same document reader the command uses.
|
||||
fn fixture_setups() -> Vec<(String, acadrust::objects::PlotSettings)> {
|
||||
let document = crate::io::load_file(std::path::Path::new(FIXTURE)).unwrap();
|
||||
crate::scene::document_page_setups(&document)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fixture_reader_lists_its_named_page_setups() {
|
||||
// The fixture carries its page setups on the layouts, not as named
|
||||
// setups; a drawing with named ones lists them in dictionary order.
|
||||
assert!(fixture_setups().is_empty());
|
||||
let mut app = app();
|
||||
let i = app.active_tab;
|
||||
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
app.tabs[i].scene.page_setup_save("Site", ps.clone());
|
||||
app.tabs[i].scene.page_setup_save("Detail", ps);
|
||||
let names: Vec<String> = crate::scene::document_page_setups(&app.tabs[i].scene.document)
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
assert_eq!(names, ["Site", "Detail"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psetupin_imports_named_setups_from_a_saved_drawing() {
|
||||
use acadrust::objects::PlotRotation;
|
||||
// Author a source drawing with two named setups and save it.
|
||||
let dir = std::env::temp_dir().join(format!("ocs-psetupin-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let source = dir.join("source.dxf");
|
||||
{
|
||||
let mut app = app();
|
||||
let i = app.active_tab;
|
||||
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
ps.printer_name = "DWG To PDF.pc3".into();
|
||||
ps.paper_size = "ISO_A3_(297.00_x_420.00_MM)".into();
|
||||
ps.paper_width = 297.0;
|
||||
ps.paper_height = 420.0;
|
||||
ps.rotation = PlotRotation::Degrees90;
|
||||
ps.plot_view_handle = acadrust::Handle::new(0xBEEF);
|
||||
app.tabs[i].scene.page_setup_save("Site", ps.clone());
|
||||
ps.paper_size = "ISO_A2_(420.00_x_594.00_MM)".into();
|
||||
app.tabs[i].scene.page_setup_save("Overview", ps);
|
||||
let save = format!(r#"{{"op":"save","path":"{}"}}"#, source.display());
|
||||
let reply = app.automation_op(&save);
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
}
|
||||
let mut app = app();
|
||||
let i = app.active_tab;
|
||||
// One named setup already exists under a name the file also has.
|
||||
let own = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
app.tabs[i].scene.page_setup_save("Site", own);
|
||||
// `*` imports everything, replacing the same-named one.
|
||||
let _ = app.run_command_line(&format!("PSETUPIN {} *", source.display()));
|
||||
// The report is translated on localized systems; the counts are not.
|
||||
let report = last_line(&app);
|
||||
assert!(report.starts_with("PSETUPIN"), "{report}");
|
||||
assert!(report.contains('2') && report.contains('1'), "{report}");
|
||||
let names = app.tabs[i].scene.page_setup_names();
|
||||
assert_eq!(names, ["Site", "Overview"]);
|
||||
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!(app.tabs[i].dirty);
|
||||
// Undo takes the whole import back.
|
||||
let _ = app.update(Message::Undo);
|
||||
let names = app.tabs[i].scene.page_setup_names();
|
||||
assert_eq!(names, ["Site"]);
|
||||
assert_ne!(
|
||||
app.tabs[i].scene.page_setup_get("Site").unwrap().paper_size,
|
||||
"ISO_A3_(297.00_x_420.00_MM)"
|
||||
);
|
||||
// A name list imports just those and reports the unknown ones.
|
||||
let _ = app.run_command_line(&format!("PSETUPIN {} Overview,Nothing", source.display()));
|
||||
assert_eq!(app.tabs[i].scene.page_setup_names(), ["Site", "Overview"]);
|
||||
// A missing file is an error, not a crash.
|
||||
let _ = app.run_command_line("PSETUPIN /nowhere/at/all.dwg *");
|
||||
assert!(last_line(&app).starts_with("PSETUPIN:"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chooser_imports_the_ticked_setups_and_selects_the_first() {
|
||||
let mut app = app();
|
||||
let i = app.active_tab;
|
||||
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
let _ = app.on_plot_dialog_open();
|
||||
let setups = ["A", "B", "C"]
|
||||
.into_iter()
|
||||
.map(|name| (name.to_string(), ps.clone()))
|
||||
.collect();
|
||||
let loaded = Ok(("other.dwg".to_string(), setups));
|
||||
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");
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::All(false)));
|
||||
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_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(),
|
||||
Some("unreadable")
|
||||
);
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Cancel));
|
||||
assert!(app.plot_dialog.import_draft.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_layout_opens_its_page_setup_when_the_option_is_on() {
|
||||
let mut app = app();
|
||||
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");
|
||||
let _ = app.update(Message::LayoutCreate);
|
||||
assert_eq!(app.active_modal, Some(crate::app::ModalKind::Plot));
|
||||
assert_eq!(app.plot_dialog.selected_setup, "*Layout3*");
|
||||
}
|
||||
}
|
||||
@@ -62,27 +62,6 @@ pub(super) fn layout_entry_name(s: &str) -> &str {
|
||||
s.trim_start_matches('*').trim_end_matches('*')
|
||||
}
|
||||
|
||||
/// Infer an A-series label when dimensions match; otherwise retain the size.
|
||||
pub(super) fn paper_label_from_dims(w: f64, h: f64) -> (String, String) {
|
||||
use crate::io::paper_sizes::PaperSize;
|
||||
let orient = if w >= h { "Landscape" } else { "Portrait" };
|
||||
let (short, long) = if w <= h { (w, h) } else { (h, w) };
|
||||
let mut best = ("A4".to_string(), f64::INFINITY);
|
||||
for p in PaperSize::ALL {
|
||||
let (pw, ph) = p.dimensions_mm(); // portrait: pw < ph
|
||||
let err = (pw - short).abs() + (ph - long).abs();
|
||||
if err < best.1 {
|
||||
best = (p.label().to_string(), err);
|
||||
}
|
||||
}
|
||||
let label = if best.1 <= 2.0 {
|
||||
best.0
|
||||
} else {
|
||||
format!("{w:.2} × {h:.2} mm")
|
||||
};
|
||||
(label, orient.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn parse_plot_scale(s: &str) -> (f64, f64) {
|
||||
if s == "Fit" {
|
||||
return (1.0, 1.0);
|
||||
|
||||
+1259
-189
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,299 @@
|
||||
//! Snap to displayed model geometry while a paper-space command is active.
|
||||
//! Viewport queries use the renderer's resident wires and interaction index,
|
||||
//! then project the accepted model point onto the sheet. Ordinary selection
|
||||
//! continues to use paper-space hit testing.
|
||||
|
||||
use iced::Point;
|
||||
|
||||
use acadrust::types::Handle;
|
||||
|
||||
use crate::app::OpenCADStudio;
|
||||
use crate::scene::viewport_ref::{AcceptedSnap, ViewportFrame};
|
||||
use crate::snap::{SnapResult, SnapType};
|
||||
|
||||
/// Bound on the retained accepted-snap history. A command's point steps are a
|
||||
/// handful at most; the cap only stops a long polyline from growing forever.
|
||||
const MAX_ACCEPTED_SNAPS: usize = 64;
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// Snaps accepted by the active command's point steps, oldest first, in
|
||||
/// step order.
|
||||
pub(crate) fn accepted_snaps(&self) -> &[AcceptedSnap] {
|
||||
&self.accepted_snaps
|
||||
}
|
||||
|
||||
/// Infer direct sources only when the measurement uses the active space.
|
||||
/// Projected model points must retain their acquired source identities;
|
||||
/// proximity on the sheet could bind them to unrelated paper geometry.
|
||||
pub(crate) fn infer_dimension_sources_guarded(
|
||||
&self,
|
||||
tab: usize,
|
||||
dimension: Handle,
|
||||
) -> Vec<Option<Handle>> {
|
||||
if !self.dimension_association_allowed(tab) {
|
||||
return Vec::new();
|
||||
}
|
||||
self.tabs[tab].scene.infer_dimension_sources(dimension)
|
||||
}
|
||||
|
||||
/// Whether the dimension can use ordinary active-space associations.
|
||||
/// Placement clicks are excluded by the measurement classification.
|
||||
pub(crate) fn dimension_association_allowed(&self, tab: usize) -> bool {
|
||||
matches!(
|
||||
self.dimension_measure_space(tab),
|
||||
crate::app::dim_viewport::DimensionMeasureSpace::Direct
|
||||
)
|
||||
}
|
||||
|
||||
/// Forget the accepted snaps of a finished / abandoned command.
|
||||
pub(crate) fn clear_accepted_snaps(&mut self) {
|
||||
self.accepted_snaps.clear();
|
||||
}
|
||||
|
||||
/// Record the snap a point step just accepted. `snap` is the displayed snap
|
||||
/// result (already in the current space's coordinates) or `None` for a free
|
||||
/// / typed point; `committed` is the point the command actually received
|
||||
/// after ortho / polar / axis-lock / dynamic-input resolution.
|
||||
pub(crate) fn record_accepted_snap(
|
||||
&mut self,
|
||||
tab: usize,
|
||||
snap: Option<SnapResult>,
|
||||
frame: Option<ViewportFrame>,
|
||||
committed: glam::DVec3,
|
||||
) -> bool {
|
||||
let mut accepted = match snap {
|
||||
// A viewport snap keeps its frame only when the hit really came
|
||||
// through it, so a paper-sheet snap never claims a model point.
|
||||
Some(hit) => {
|
||||
let frame = hit.viewport.and(frame);
|
||||
AcceptedSnap::from_snap(&hit, frame).with_paper_point(committed)
|
||||
}
|
||||
None => AcceptedSnap::free(committed),
|
||||
};
|
||||
if self.tabs[tab]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|cmd| cmd.measures_through_viewports())
|
||||
{
|
||||
if self.tabs[tab]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|cmd| cmd.dimension_placement_pending())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if !self.dimension_acquisition_allowed(tab, accepted.viewport) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
self.resolve_source_block_path(tab, &mut accepted);
|
||||
self.push_accepted_snap(accepted);
|
||||
true
|
||||
}
|
||||
|
||||
/// Resolve the instance path once, when a feature is accepted. Failure
|
||||
/// drops source identity so a failed descent cannot attach to the insert's
|
||||
/// origin. Ambiguous intersections within one batched block remain unlinked.
|
||||
fn resolve_source_block_path(&self, tab: usize, accepted: &mut AcceptedSnap) {
|
||||
let resolve = |source: &mut crate::scene::viewport_ref::SnapSourceRef| {
|
||||
if !source.block_path.is_empty() {
|
||||
return true;
|
||||
}
|
||||
if !matches!(
|
||||
self.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(source.source.handle),
|
||||
Some(acadrust::EntityType::Insert(_))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if source.snap_type == SnapType::Insertion {
|
||||
let Some(acadrust::EntityType::Insert(insert)) = self.tabs[tab]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(source.source.handle)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let origin = glam::DVec3::new(
|
||||
insert.insert_point.x,
|
||||
insert.insert_point.y,
|
||||
insert.insert_point.z,
|
||||
);
|
||||
return origin.distance(accepted.model_point) < 1e-5;
|
||||
}
|
||||
let Some((entity, path)) = self.tabs[tab].scene.resolve_measurable_feature(
|
||||
source.source.handle,
|
||||
accepted.model_point,
|
||||
accepted.viewport,
|
||||
Some(source.snap_type),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let distance = crate::scene::viewport_dimension_pick::feature_pick_distance(
|
||||
&entity,
|
||||
accepted.model_point,
|
||||
Some(source.snap_type),
|
||||
);
|
||||
if !distance.is_some_and(|distance| distance < 1e-5) {
|
||||
return false;
|
||||
}
|
||||
source.source =
|
||||
crate::command::DimensionAssociationSource::inferred(entity.common().handle);
|
||||
source.block_path = path;
|
||||
true
|
||||
};
|
||||
let Some(source) = accepted.source.as_mut() else {
|
||||
return;
|
||||
};
|
||||
if !resolve(source) {
|
||||
accepted.source = None;
|
||||
return;
|
||||
}
|
||||
if let Some(other) = source.intersection.as_mut() {
|
||||
if !resolve(other)
|
||||
|| (other.source.handle == source.source.handle
|
||||
&& other.block_path == source.block_path)
|
||||
{
|
||||
source.intersection = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Retain a bounded history for point and object picks.
|
||||
pub(crate) fn push_accepted_snap(&mut self, accepted: AcceptedSnap) {
|
||||
if self.accepted_snaps.len() >= MAX_ACCEPTED_SNAPS {
|
||||
self.accepted_snaps.remove(0);
|
||||
}
|
||||
self.accepted_snaps.push(accepted);
|
||||
}
|
||||
/// Query visible viewport geometry with a canvas-pixel cursor and sheet point.
|
||||
/// Returns paper coordinates and canvas pixels, plus the source viewport frame.
|
||||
pub(in crate::app) fn paper_viewport_snap(
|
||||
&mut self,
|
||||
i: usize,
|
||||
cursor_canvas: Point,
|
||||
canvas: (f32, f32),
|
||||
cursor_paper: glam::DVec3,
|
||||
) -> Option<(SnapResult, ViewportFrame)> {
|
||||
if !self.snapper.snap_enabled {
|
||||
return None;
|
||||
}
|
||||
{
|
||||
let scene = &self.tabs[i].scene;
|
||||
if scene.current_layout == "Model" || scene.active_viewport.is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if canvas.0 < 1.0 || canvas.1 < 1.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
for frame in self.tabs[i]
|
||||
.scene
|
||||
.viewport_frames_at_paper_point(cursor_paper)
|
||||
{
|
||||
let handle = frame.viewport;
|
||||
let Some((cam, rect)) = self.tabs[i].scene.viewport_edit_frame_for(handle, canvas)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if rect.width < 1.0 || rect.height < 1.0 {
|
||||
continue;
|
||||
}
|
||||
let bounds = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
let local = Point::new(cursor_canvas.x - rect.x, cursor_canvas.y - rect.y);
|
||||
let view_rot = cam.view_proj_rte(bounds);
|
||||
let eye = cam.eye();
|
||||
// The cursor in MODEL space, through the frame — exactly the point
|
||||
// the viewport camera projects back onto `local`.
|
||||
let model_cursor = frame.paper_to_model(cursor_paper);
|
||||
|
||||
let wires = self.tabs[i]
|
||||
.scene
|
||||
.model_wires_for_viewport_arc(handle, bounds.height);
|
||||
let candidates = self.tabs[i].scene.interaction_candidates_near(
|
||||
wires,
|
||||
model_cursor,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
self.snapper.osnap_radius_px,
|
||||
);
|
||||
|
||||
// The grid belongs to the sheet. Translate the construction
|
||||
// anchor for perpendicular/tangent snaps into this viewport's
|
||||
// model coordinates, then restore the shared snapper state.
|
||||
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.as_dvec3()).as_vec3());
|
||||
let hit = self.snapper.snap(
|
||||
model_cursor,
|
||||
local,
|
||||
&candidates,
|
||||
view_rot,
|
||||
eye,
|
||||
bounds,
|
||||
glam::Vec3::ZERO,
|
||||
(glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z),
|
||||
None,
|
||||
);
|
||||
self.snapper.grid_snap_on = saved_grid;
|
||||
self.snapper.from_point = saved_from;
|
||||
|
||||
let Some(hit) = hit.filter(|h| h.snap_type != SnapType::Grid) else {
|
||||
continue;
|
||||
};
|
||||
// The snap aperture can reach outside the visible clip boundary.
|
||||
let paper = frame.model_to_paper(hit.world);
|
||||
if !self.tabs[i]
|
||||
.scene
|
||||
.viewport_displays_paper_point(handle, paper.truncate())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return Some((project_hit_to_paper(hit, &frame, rect, handle), frame));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-express a snap taken in a viewport's model space as a paper-space result:
|
||||
/// the point projects onto the sheet through `frame`, and the pane-local pixel
|
||||
/// positions shift by the viewport's screen origin so they share the canvas
|
||||
/// frame with the ordinary paper snap.
|
||||
fn project_hit_to_paper(
|
||||
hit: SnapResult,
|
||||
frame: &ViewportFrame,
|
||||
rect: iced::Rectangle,
|
||||
viewport: Handle,
|
||||
) -> SnapResult {
|
||||
let to_canvas = |p: Point| Point::new(p.x + rect.x, p.y + rect.y);
|
||||
SnapResult {
|
||||
world: frame.model_to_paper(hit.world),
|
||||
screen: to_canvas(hit.screen),
|
||||
snap_type: hit.snap_type,
|
||||
// Tangent geometry is model-space and would be consumed as paper-space
|
||||
// by TTR/TTT; drop it rather than hand over a wrong circle.
|
||||
tangent_obj: None,
|
||||
extension_base: hit.extension_base.map(to_canvas),
|
||||
extension_base2: hit.extension_base2.map(to_canvas),
|
||||
extension_origin: hit.extension_origin.map(|p| frame.model_to_paper(p)),
|
||||
extension_dir: hit.extension_dir.map(|d| {
|
||||
let mapped = frame.model_to_paper_dir(d.truncate());
|
||||
glam::DVec3::new(mapped.x, mapped.y, d.z)
|
||||
}),
|
||||
viewport: Some(viewport),
|
||||
source: hit.source,
|
||||
secondary_source: hit.secondary_source,
|
||||
model_point: Some(hit.world),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user