feat(view): complete ZOOM options
Add command-line navigation modes and persistent wheel controls.\n\nRefs #412
This commit is contained in:
parent
3f103d2f8b
commit
adb8ced8b5
17 changed files with 380 additions and 23 deletions
|
|
@ -120,6 +120,7 @@ QS, *QSELECT
|
|||
FI, *FILTER
|
||||
|
||||
; ── View / display ────────────────────────────────────────────────────────
|
||||
Z, *ZOOM
|
||||
P, *PAN
|
||||
U, *UNDO
|
||||
PR, *PROPERTIES
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ impl OpenCADStudio {
|
|||
self.ucs_icon_hover = false;
|
||||
self.tabs[i].pan_mode = false;
|
||||
self.tabs[i].orbit_mode = false;
|
||||
self.tabs[i].zoom_dynamic_mode = false;
|
||||
let _ = self.on_viewport_exit();
|
||||
}
|
||||
|
||||
|
|
@ -1939,6 +1940,7 @@ impl OpenCADStudio {
|
|||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.zoom_to_window(p1.as_vec3(), p2.as_vec3());
|
||||
|
|
|
|||
|
|
@ -805,40 +805,94 @@ impl OpenCADStudio {
|
|||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
|
||||
"ZOOM EXTENTS ALL" | "ZOOM EXTENTS ALL VIEWPORTS" | "ZEA" => {
|
||||
"ZOOM EXTENTS ALL" | "ZOOM EXTENTS ALL VIEWPORTS" | "ZOOM EA" | "ZEA" => {
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
self.tabs[i].scene.fit_all_model_viewports();
|
||||
self.command_line.push_output(crate::t!("Zoom Extents — All Viewports").as_ref());
|
||||
}
|
||||
|
||||
"ZOOM EXTENTS" | "ZOOMEXTENTS" | "ZE" => {
|
||||
"ZOOM EXTENTS" | "ZOOM E" | "ZOOMEXTENTS" | "ZE" => {
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
self.tabs[i].scene.fit_all();
|
||||
self.command_line.push_output(crate::t!("Zoom Extents").as_ref());
|
||||
}
|
||||
|
||||
"ZOOM IN" | "ZI" => {
|
||||
"ZOOM IN" | "ZOOM I" | "ZI" => {
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
self.tabs[i].scene.zoom_camera(1.0 / 1.5);
|
||||
self.command_line.push_output(crate::t!("Zoom In").as_ref());
|
||||
}
|
||||
|
||||
"ZOOM OUT" | "ZO" => {
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
self.tabs[i].scene.zoom_camera(1.5);
|
||||
self.command_line.push_output(crate::t!("Zoom Out").as_ref());
|
||||
}
|
||||
|
||||
// ZOOM ALL — fit the configured drawing limits.
|
||||
"ZOOM ALL" | "ZOOM A" | "ZA" => {
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
self.tabs[i].scene.fit_all_with_limits();
|
||||
self.command_line.push_output(crate::t!("Zoom All").as_ref());
|
||||
}
|
||||
|
||||
"ZOOM PREVIOUS" | "ZOOM P" | "ZP" => {
|
||||
if self.tabs[i].scene.restore_previous_view() {
|
||||
self.command_line.push_output(crate::t!("Zoom Previous").as_ref());
|
||||
} else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("ZOOM: no previous view.").as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
"ZOOM OBJECT" | "ZOOM O" | "ZOBJ" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(handle, _)| handle)
|
||||
.collect();
|
||||
if handles.is_empty() {
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let command = SelectObjectsCommand::new("ZOOM OBJECT");
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
} else {
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
if self.tabs[i].scene.zoom_to_entities(&handles) {
|
||||
self.command_line.push_output(crate::t!("Zoom Object").as_ref());
|
||||
} else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("ZOOM: selected objects have no visible bounds.").as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"ZOOM DYNAMIC" | "ZOOM D" | "ZD" => {
|
||||
self.tabs[i].zoom_dynamic_mode = true;
|
||||
self.clear_navigation_hover(i);
|
||||
self.command_line.push_output(
|
||||
crate::t!(
|
||||
"ZOOM Dynamic: drag horizontally to pan and vertically to zoom. Press Esc to exit."
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
|
||||
// ZOOM SCALE — set zoom factor (e.g. "ZOOM SCALE 2" or "ZS 0.5")
|
||||
cmd if cmd.starts_with("ZOOM SCALE ") || cmd.starts_with("ZS ") => {
|
||||
cmd if cmd.starts_with("ZOOM SCALE ")
|
||||
|| cmd.starts_with("ZOOM S ")
|
||||
|| cmd.starts_with("ZS ") =>
|
||||
{
|
||||
let rest = cmd
|
||||
.split_once(' ')
|
||||
.and_then(|(_, r)| r.split_once(' ').map(|(_, v)| v).or(Some(r)))
|
||||
.strip_prefix("ZOOM SCALE ")
|
||||
.or_else(|| cmd.strip_prefix("ZOOM S "))
|
||||
.or_else(|| cmd.strip_prefix("ZS "))
|
||||
.unwrap_or("1");
|
||||
if let Ok(factor) = rest.trim().parse::<f32>() {
|
||||
if factor > 0.0 {
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
self.tabs[i].scene.zoom_camera(1.0 / factor);
|
||||
self.command_line
|
||||
.push_output(crate::tf!("Zoom Scale ×{factor:.3}").as_ref());
|
||||
|
|
@ -866,15 +920,18 @@ impl OpenCADStudio {
|
|||
use crate::command::KeywordCommand;
|
||||
let c = KeywordCommand::new(
|
||||
"ZOOM",
|
||||
"ZOOM [Window / Extents / Extents All / All / In / Out / Scale]:",
|
||||
"ZOOM [Window / Extents / Previous / Object / All / Dynamic / Extents All / In / Out / Scale]:",
|
||||
vec![
|
||||
("Window", "WINDOW", None),
|
||||
("Extents", "EXTENTS", None),
|
||||
("Extents All", "EXTENTS ALL", None),
|
||||
("All", "ALL", None),
|
||||
("In", "IN", None),
|
||||
("Window", "W", None),
|
||||
("Extents", "E", None),
|
||||
("Previous", "P", None),
|
||||
("Object", "O", None),
|
||||
("All", "A", None),
|
||||
("Dynamic", "D", None),
|
||||
("Extents All", "EA", None),
|
||||
("In", "I", None),
|
||||
("Out", "OUT", None),
|
||||
("Scale", "SCALE", Some("ZOOM scale factor (e.g. 2 or 0.5):")),
|
||||
("Scale", "S", Some("ZOOM scale factor (e.g. 2 or 0.5):")),
|
||||
],
|
||||
);
|
||||
self.command_line.push_info(&c.prompt());
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ impl OpenCADStudio {
|
|||
// command arms below re-enable the selected one).
|
||||
self.tabs[i].pan_mode = false;
|
||||
self.tabs[i].orbit_mode = false;
|
||||
self.tabs[i].zoom_dynamic_mode = false;
|
||||
// Reset the last committed point so the first click of the new command
|
||||
// is not constrained by ortho/polar relative to a previous command's endpoint.
|
||||
self.last_point = None;
|
||||
|
|
@ -445,6 +446,8 @@ inventory::submit!(crate::command::CommandRegistration {
|
|||
"FITSPLINE",
|
||||
// System variables (typeable directly).
|
||||
"MIRRTEXT",
|
||||
"ZOOMWHEEL",
|
||||
"ZOOMFACTOR",
|
||||
"ATTREQ",
|
||||
"ATTDIA",
|
||||
"DIMASSOC",
|
||||
|
|
|
|||
|
|
@ -866,6 +866,8 @@ impl OpenCADStudio {
|
|||
cmd if matches!(
|
||||
cmd.split_whitespace().next().unwrap_or(""),
|
||||
"MIRRTEXT"
|
||||
| "ZOOMWHEEL"
|
||||
| "ZOOMFACTOR"
|
||||
| "TEXTFILL"
|
||||
| "ATTREQ"
|
||||
| "ATTDIA"
|
||||
|
|
@ -936,7 +938,7 @@ impl OpenCADStudio {
|
|||
let value = it.next().map(|s| s.trim().to_string());
|
||||
if name.is_empty() || name == "?" {
|
||||
self.command_line.push_info(
|
||||
"SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)",
|
||||
"SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT ZOOMWHEEL ZOOMFACTOR ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR | CLAYER CELTYPE TEXTSTYLE (read-only)",
|
||||
);
|
||||
} else {
|
||||
// Parse a boolean given as 0/1 or ON/OFF.
|
||||
|
|
@ -1028,6 +1030,37 @@ impl OpenCADStudio {
|
|||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)),
|
||||
},
|
||||
"ZOOMWHEEL" => match &value {
|
||||
Some(v) => match parse_bool(v) {
|
||||
Some(reversed) => {
|
||||
self.zoom_wheel_reversed = reversed;
|
||||
Ok((
|
||||
format!("ZOOMWHEEL = {}", reversed as i32),
|
||||
true,
|
||||
))
|
||||
}
|
||||
None => Err("SETVAR: 0 or 1 required.".into()),
|
||||
},
|
||||
None => Ok((
|
||||
format!(
|
||||
"ZOOMWHEEL = {}",
|
||||
self.zoom_wheel_reversed as i32
|
||||
),
|
||||
false,
|
||||
)),
|
||||
},
|
||||
"ZOOMFACTOR" => match &value {
|
||||
Some(v) => match v.parse::<i32>() {
|
||||
Ok(factor) if (3..=100).contains(&factor) => {
|
||||
self.zoom_factor = factor;
|
||||
Ok((format!("ZOOMFACTOR = {factor}"), true))
|
||||
}
|
||||
_ => Err("SETVAR: integer from 3 to 100 required.".into()),
|
||||
},
|
||||
None => {
|
||||
Ok((format!("ZOOMFACTOR = {}", self.zoom_factor), false))
|
||||
}
|
||||
},
|
||||
// Global (not stored in the drawing): fill vs. hollow
|
||||
// TrueType text. The active tab re-tessellates below.
|
||||
"TEXTFILL" => match &value {
|
||||
|
|
@ -1579,7 +1612,11 @@ impl OpenCADStudio {
|
|||
match outcome {
|
||||
Ok((msg, changed)) => {
|
||||
if changed {
|
||||
self.tabs[i].dirty = true;
|
||||
if matches!(name.as_str(), "ZOOMWHEEL" | "ZOOMFACTOR") {
|
||||
self.persist_settings_if_changed();
|
||||
} else {
|
||||
self.tabs[i].dirty = true;
|
||||
}
|
||||
self.command_line.push_output(&msg);
|
||||
} else {
|
||||
// Queried with no value (`changed == false`):
|
||||
|
|
|
|||
|
|
@ -212,6 +212,9 @@ pub(super) struct DocumentTab {
|
|||
/// Interactive 3-D orbit mode. While active, a left-button drag follows
|
||||
/// the same camera-orbit path as Shift + middle-button drag.
|
||||
pub(super) orbit_mode: bool,
|
||||
/// Interactive ZOOM Dynamic mode. A left drag pans horizontally and zooms
|
||||
/// vertically until Escape or another command ends the mode.
|
||||
pub(super) zoom_dynamic_mode: bool,
|
||||
/// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
|
||||
|
|
@ -476,6 +479,7 @@ impl DocumentTab {
|
|||
is_start: false,
|
||||
pan_mode: false,
|
||||
orbit_mode: false,
|
||||
zoom_dynamic_mode: false,
|
||||
plugin_state: HashMap::new(),
|
||||
suspended_cmd: None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -444,6 +444,10 @@ pub(super) struct OpenCADStudio {
|
|||
polar_mode: bool,
|
||||
/// Polar tracking angle increment in degrees (15 / 30 / 45 / 90).
|
||||
polar_increment_deg: f32,
|
||||
/// Reverse the mouse-wheel zoom direction when true (ZOOMWHEEL = 1).
|
||||
zoom_wheel_reversed: bool,
|
||||
/// Mouse-wheel zoom sensitivity, clamped to 3..=100 (ZOOMFACTOR).
|
||||
zoom_factor: i32,
|
||||
/// Show grid lines in the viewport (F7).
|
||||
show_grid: bool,
|
||||
/// Dynamic input overlay (F12): show coordinate tooltip near cursor.
|
||||
|
|
@ -3030,6 +3034,8 @@ impl OpenCADStudio {
|
|||
ortho_mode: false,
|
||||
polar_mode: false,
|
||||
polar_increment_deg: 45.0,
|
||||
zoom_wheel_reversed: false,
|
||||
zoom_factor: 60,
|
||||
show_grid: false,
|
||||
dyn_input: true,
|
||||
texteditmode: false,
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ pub struct UserSettings {
|
|||
pub dyn_input: bool,
|
||||
pub polar: bool,
|
||||
pub polar_increment_deg: f32,
|
||||
pub zoom_wheel_reversed: bool,
|
||||
pub zoom_factor: i32,
|
||||
pub otrack: bool,
|
||||
// Ortho ($ORTHOMODE) and the running OSNAP set ($OSMODE) are per-drawing —
|
||||
// stored in the document header, not here (they used to be persisted app-
|
||||
|
|
@ -166,6 +168,8 @@ impl Default for UserSettings {
|
|||
dyn_input: true,
|
||||
polar: false,
|
||||
polar_increment_deg: 45.0,
|
||||
zoom_wheel_reversed: false,
|
||||
zoom_factor: 60,
|
||||
otrack: false,
|
||||
default_assoc_prompted: false,
|
||||
disabled_plugins: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -660,16 +660,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||
// drag. Orbit exits silently; PAN keeps its existing message.
|
||||
if self.tabs[self.active_tab].pan_mode
|
||||
|| self.tabs[self.active_tab].orbit_mode
|
||||
|| self.tabs[self.active_tab].zoom_dynamic_mode
|
||||
{
|
||||
let i = self.active_tab;
|
||||
let was_pan = self.tabs[i].pan_mode;
|
||||
self.tabs[i].pan_mode = false;
|
||||
self.tabs[i].orbit_mode = false;
|
||||
self.tabs[i].zoom_dynamic_mode = false;
|
||||
{
|
||||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||
sel.middle_down = false;
|
||||
sel.middle_last_pos = None;
|
||||
sel.orbit_pivot = None;
|
||||
sel.box_anchor = None;
|
||||
sel.box_anchor_world = None;
|
||||
sel.box_current = None;
|
||||
sel.box_crossing_locked = false;
|
||||
}
|
||||
if was_pan {
|
||||
self.command_line.push_output(crate::t!("PAN ended.").as_ref());
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
|||
&& self.active_modal.is_none()
|
||||
&& !self.tabs[i].pan_mode
|
||||
&& !self.tabs[i].orbit_mode
|
||||
&& !self.tabs[i].zoom_dynamic_mode
|
||||
{
|
||||
self.ribbon.deactivate_tool();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -265,6 +265,8 @@ impl OpenCADStudio {
|
|||
dyn_input: self.dyn_input,
|
||||
polar: self.polar_mode,
|
||||
polar_increment_deg: self.polar_increment_deg,
|
||||
zoom_wheel_reversed: self.zoom_wheel_reversed,
|
||||
zoom_factor: self.zoom_factor,
|
||||
otrack: self.snapper.otrack_enabled,
|
||||
default_assoc_prompted: self.default_assoc_prompted,
|
||||
disabled_plugins: {
|
||||
|
|
@ -299,6 +301,8 @@ impl OpenCADStudio {
|
|||
self.dyn_input = s.dyn_input;
|
||||
self.polar_mode = s.polar;
|
||||
self.polar_increment_deg = s.polar_increment_deg;
|
||||
self.zoom_wheel_reversed = s.zoom_wheel_reversed;
|
||||
self.zoom_factor = s.zoom_factor.clamp(3, 100);
|
||||
// Ortho + running OSNAP are per-drawing (adopted from the header on
|
||||
// open / tab switch), not app-global, so they are not applied here.
|
||||
self.snapper.otrack_enabled = s.otrack;
|
||||
|
|
|
|||
|
|
@ -2831,6 +2831,7 @@ impl OpenCADStudio {
|
|||
Message::ViewCubeHome => {
|
||||
let i = self.active_tab;
|
||||
self.clear_navigation_hover(i);
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
let r_ucs = self.tabs[i].scene.viewcube_ucs_mat();
|
||||
if self.tabs[i].scene.active_viewport.is_some() {
|
||||
self.tabs[i]
|
||||
|
|
@ -2847,6 +2848,7 @@ impl OpenCADStudio {
|
|||
Message::ViewCubeRoll(cw) => {
|
||||
let i = self.active_tab;
|
||||
self.clear_navigation_hover(i);
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
let ang = if cw {
|
||||
std::f32::consts::FRAC_PI_2
|
||||
} else {
|
||||
|
|
@ -2873,6 +2875,7 @@ impl OpenCADStudio {
|
|||
};
|
||||
let i = self.active_tab;
|
||||
self.clear_navigation_hover(i);
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
if self.tabs[i].scene.active_viewport.is_some() {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
|
|
|
|||
|
|
@ -739,6 +739,39 @@ impl OpenCADStudio {
|
|||
if mid_down {
|
||||
if let Some(last) = mid_last {
|
||||
let (dx, dy) = (p.x - last.x, p.y - last.y);
|
||||
if self.tabs[i].zoom_dynamic_mode {
|
||||
let bounds = self.tabs[i]
|
||||
.scene
|
||||
.active_model_tile_bounds(vp_size.0, vp_size.1);
|
||||
drop(sel);
|
||||
let zoom_delta = -dy * 0.03;
|
||||
if self.tabs[i].scene.active_viewport.is_some() {
|
||||
self.tabs[i].scene.pan_active_viewport(dx, 0.0, bounds);
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.zoom_active_viewport(zoom_delta, None);
|
||||
} else {
|
||||
let local = iced::Point {
|
||||
x: p.x - bounds.x,
|
||||
y: p.y - bounds.y,
|
||||
};
|
||||
let local_bounds = iced::Rectangle {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
};
|
||||
let mut camera = self.tabs[i].scene.camera.borrow_mut();
|
||||
camera.pan_screen(dx, 0.0, bounds.height);
|
||||
camera.zoom_about_point(local, local_bounds, zoom_delta);
|
||||
}
|
||||
self.tabs[i].scene.camera_generation += 1;
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.record_nav_perf(crate::scene::NavPerfOp::Zoom, move_started);
|
||||
self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p);
|
||||
return Task::none();
|
||||
}
|
||||
// Shift+MMB drag orbits the model view instead of panning
|
||||
// — the requested Zoom=wheel / Pan=MMB / Rotate=Shift+MMB
|
||||
// scheme (#229). Floating viewports and paper keep the
|
||||
|
|
@ -2288,11 +2321,21 @@ impl OpenCADStudio {
|
|||
|
||||
// Interactive navigation tools reuse the middle-button movement path,
|
||||
// so no selection/pick logic runs while the left button drives them.
|
||||
if self.tabs[i].orbit_mode || self.tabs[i].pan_mode {
|
||||
if self.tabs[i].orbit_mode
|
||||
|| self.tabs[i].pan_mode
|
||||
|| self.tabs[i].zoom_dynamic_mode
|
||||
{
|
||||
self.clear_navigation_hover(i);
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||
sel.middle_down = true;
|
||||
sel.middle_last_pos = Some(p);
|
||||
if self.tabs[i].zoom_dynamic_mode {
|
||||
sel.box_anchor = Some(p);
|
||||
sel.box_current = Some(p);
|
||||
sel.box_crossing = false;
|
||||
sel.box_crossing_locked = true;
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
|
|
@ -2460,11 +2503,18 @@ impl OpenCADStudio {
|
|||
|
||||
// Navigation mode: end this drag but keep the tool armed for the next
|
||||
// left drag (exit is Esc / another command). Mirror of the press.
|
||||
if self.tabs[i].orbit_mode || self.tabs[i].pan_mode {
|
||||
if self.tabs[i].orbit_mode
|
||||
|| self.tabs[i].pan_mode
|
||||
|| self.tabs[i].zoom_dynamic_mode
|
||||
{
|
||||
let mut sel = self.tabs[i].scene.selection.borrow_mut();
|
||||
sel.middle_down = false;
|
||||
sel.middle_last_pos = None;
|
||||
sel.orbit_pivot = None;
|
||||
sel.box_anchor = None;
|
||||
sel.box_anchor_world = None;
|
||||
sel.box_current = None;
|
||||
sel.box_crossing_locked = false;
|
||||
drop(sel);
|
||||
self.arm_hover_after_navigation(i);
|
||||
return Task::none();
|
||||
|
|
@ -4007,6 +4057,7 @@ impl OpenCADStudio {
|
|||
let i = self.active_tab;
|
||||
self.clear_navigation_hover(i);
|
||||
self.ribbon.close_dropdown();
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
let now = Instant::now();
|
||||
let is_double = {
|
||||
let sel = self.tabs[i].scene.selection.borrow();
|
||||
|
|
@ -4035,11 +4086,16 @@ impl OpenCADStudio {
|
|||
|
||||
pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task<Message> {
|
||||
let nav_started = Instant::now();
|
||||
let s = match delta {
|
||||
let mut s = match delta {
|
||||
mouse::ScrollDelta::Lines { y, .. } => y,
|
||||
mouse::ScrollDelta::Pixels { y, .. } => y * 0.01,
|
||||
};
|
||||
s *= self.zoom_factor as f32 / 60.0;
|
||||
if self.zoom_wheel_reversed {
|
||||
s = -s;
|
||||
}
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
self.clear_navigation_hover(i);
|
||||
let cursor = self.tabs[i].scene.selection.borrow().last_move_pos;
|
||||
let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size;
|
||||
|
|
@ -4204,6 +4260,7 @@ impl OpenCADStudio {
|
|||
fn snap_view_region(&mut self, region: CubeRegion, r_ucs: glam::Mat4) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
self.clear_navigation_hover(i);
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
let mut region = region;
|
||||
// "Already there → flip to opposite" check: compare the
|
||||
// current gaze direction with the region's target gaze.
|
||||
|
|
|
|||
|
|
@ -680,7 +680,7 @@ impl OpenCADStudio {
|
|||
dividers,
|
||||
pane_move_rect,
|
||||
pane_drop_rect,
|
||||
tab.pan_mode || tab.orbit_mode,
|
||||
tab.pan_mode || tab.orbit_mode || tab.zoom_dynamic_mode,
|
||||
self.ribbon.open_dropdown.is_some(),
|
||||
hover_locked,
|
||||
crosshair_background(tab, is_paper),
|
||||
|
|
|
|||
|
|
@ -566,8 +566,9 @@ impl CadCommand for KeywordCommand {
|
|||
// `None` would hand the same text to the command a second time.
|
||||
None => {
|
||||
let up = t.to_uppercase();
|
||||
let Some((_, keyword, value_prompt)) =
|
||||
self.options.iter().find(|(_, k, _)| k.eq_ignore_ascii_case(&up))
|
||||
let Some((_, keyword, value_prompt)) = self.options.iter().find(|(label, k, _)| {
|
||||
k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t)
|
||||
})
|
||||
else {
|
||||
// Unknown verb — keep prompting rather than dispatch garbage.
|
||||
return Some(CmdResult::NeedPoint);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@
|
|||
// hit-testing lives in `scene::pick::hit_test`.)
|
||||
use super::*;
|
||||
|
||||
fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option<glam::DVec3> {
|
||||
fn rendered_wire_bounds(
|
||||
wires: &[WireModel],
|
||||
fallback_z: f64,
|
||||
) -> Option<(glam::DVec3, glam::DVec3)> {
|
||||
let mut min = glam::DVec3::splat(f64::INFINITY);
|
||||
let mut max = glam::DVec3::splat(f64::NEG_INFINITY);
|
||||
let mut include = |point: glam::DVec3| {
|
||||
|
|
@ -55,12 +58,16 @@ fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option<glam::DV
|
|||
}
|
||||
|
||||
if min.is_finite() && max.is_finite() {
|
||||
Some((min + max) * 0.5)
|
||||
Some((min, max))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_wire_center(wires: &[WireModel], fallback_z: f64) -> Option<glam::DVec3> {
|
||||
rendered_wire_bounds(wires, fallback_z).map(|(min, max)| (min + max) * 0.5)
|
||||
}
|
||||
|
||||
fn block_entity_transform(
|
||||
document: &CadDocument,
|
||||
block_name: &str,
|
||||
|
|
@ -189,6 +196,105 @@ impl Scene {
|
|||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
/// Save the active view immediately before a navigation operation. The
|
||||
/// paper-space camera and an entered floating viewport use different state,
|
||||
/// so the snapshot records whichever one currently owns navigation.
|
||||
pub fn remember_current_view(&mut self) {
|
||||
let layout = self.current_layout.clone();
|
||||
if let Some(handle) = self.active_viewport {
|
||||
if let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) {
|
||||
self.previous_view = Some(ViewSnapshot::Floating {
|
||||
layout,
|
||||
handle,
|
||||
viewport: Box::new(viewport.clone()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.previous_view = Some(ViewSnapshot::Main {
|
||||
layout,
|
||||
tile: self.active_model_tile.get(),
|
||||
camera: self.camera.borrow().clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Swap the active view with the most recently remembered one. Keeping the
|
||||
/// displaced current view makes repeated ZOOM Previous calls toggle cleanly
|
||||
/// between the last two views.
|
||||
pub fn restore_previous_view(&mut self) -> bool {
|
||||
let Some(snapshot) = self.previous_view.take() else {
|
||||
return false;
|
||||
};
|
||||
match snapshot {
|
||||
ViewSnapshot::Main {
|
||||
layout,
|
||||
tile,
|
||||
camera,
|
||||
} if self.active_viewport.is_none()
|
||||
&& self.current_layout == layout
|
||||
&& (layout != "Model" || self.active_model_tile.get() == tile) =>
|
||||
{
|
||||
let current = ViewSnapshot::Main {
|
||||
layout,
|
||||
tile,
|
||||
camera: self.camera.borrow().clone(),
|
||||
};
|
||||
*self.camera.borrow_mut() = camera;
|
||||
self.previous_view = Some(current);
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
ViewSnapshot::Floating {
|
||||
layout,
|
||||
handle,
|
||||
viewport,
|
||||
} if self.current_layout == layout && self.active_viewport == Some(handle) => {
|
||||
let Some(EntityType::Viewport(current)) = self.document.get_entity(handle) else {
|
||||
self.previous_view = Some(ViewSnapshot::Floating {
|
||||
layout,
|
||||
handle,
|
||||
viewport,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
if current.status.locked {
|
||||
self.previous_view = Some(ViewSnapshot::Floating {
|
||||
layout,
|
||||
handle,
|
||||
viewport,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
let current = Box::new(current.clone());
|
||||
if let Some(EntityType::Viewport(destination)) =
|
||||
self.document.get_entity_mut(handle)
|
||||
{
|
||||
destination.view_center = viewport.view_center;
|
||||
destination.view_direction = viewport.view_direction;
|
||||
destination.view_target = viewport.view_target;
|
||||
destination.lens_length = viewport.lens_length;
|
||||
destination.front_clip_z = viewport.front_clip_z;
|
||||
destination.back_clip_z = viewport.back_clip_z;
|
||||
destination.view_height = viewport.view_height;
|
||||
destination.twist_angle = viewport.twist_angle;
|
||||
destination.custom_scale = viewport.custom_scale;
|
||||
destination.status.perspective = viewport.status.perspective;
|
||||
}
|
||||
self.previous_view = Some(ViewSnapshot::Floating {
|
||||
layout,
|
||||
handle,
|
||||
viewport: current,
|
||||
});
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
snapshot => {
|
||||
self.previous_view = Some(snapshot);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh model bounds without moving the live camera. Reusing `fit_all`
|
||||
/// keeps projection framing on the same visibility and outlier filters as
|
||||
/// Zoom Extents instead of accepting every finite cached AABB.
|
||||
|
|
@ -270,6 +376,52 @@ impl Scene {
|
|||
self.camera_generation += 1;
|
||||
}
|
||||
|
||||
/// Fit the active view to the rendered bounds of the supplied entities.
|
||||
/// Returns false when the selection has no finite visible geometry.
|
||||
pub fn zoom_to_entities(&mut self, handles: &[Handle]) -> bool {
|
||||
let fallback_z = self.active_camera_target().z;
|
||||
let wires = self.wire_models_for(handles);
|
||||
let mut bounds = rendered_wire_bounds(&wires, fallback_z);
|
||||
|
||||
for handle in handles {
|
||||
let Some(mesh) = self.meshes.get(handle) else {
|
||||
continue;
|
||||
};
|
||||
let [x0, y0, x1, y1] = mesh.world_aabb;
|
||||
let [z0, z1] = mesh.z_aabb;
|
||||
let mesh_min = glam::DVec3::new(x0 as f64, y0 as f64, z0 as f64);
|
||||
let mesh_max = glam::DVec3::new(x1 as f64, y1 as f64, z1 as f64);
|
||||
if !mesh_min.is_finite() || !mesh_max.is_finite() {
|
||||
continue;
|
||||
}
|
||||
bounds = Some(match bounds {
|
||||
Some((min, max)) => (min.min(mesh_min), max.max(mesh_max)),
|
||||
None => (mesh_min, mesh_max),
|
||||
});
|
||||
}
|
||||
|
||||
let Some((mut min, mut max)) = bounds else {
|
||||
return false;
|
||||
};
|
||||
if min == max {
|
||||
min -= glam::DVec3::splat(1.0);
|
||||
max += glam::DVec3::splat(1.0);
|
||||
}
|
||||
let min = min.as_vec3();
|
||||
let max = max.as_vec3();
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return false;
|
||||
}
|
||||
if self.active_viewport.is_some() {
|
||||
return self.fit_active_viewport_to_bounds(min, max);
|
||||
}
|
||||
let aspect = self.active_camera_aspect();
|
||||
self.camera.borrow_mut().fit_to_bounds(min, max, aspect);
|
||||
self.projection_bounds_epoch.set(self.geometry_epoch);
|
||||
self.camera_generation += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Centre the active camera on one rendered entity without changing zoom
|
||||
/// distance, orientation, or viewport scale.
|
||||
pub fn center_camera_on_entity(&mut self, handle: Handle) -> bool {
|
||||
|
|
|
|||
|
|
@ -1123,6 +1123,20 @@ pub(crate) struct ModelTile {
|
|||
pub(crate) snap_on: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum ViewSnapshot {
|
||||
Main {
|
||||
layout: String,
|
||||
tile: usize,
|
||||
camera: Camera,
|
||||
},
|
||||
Floating {
|
||||
layout: String,
|
||||
handle: Handle,
|
||||
viewport: Box<acadrust::entities::Viewport>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Gap (pixels) between Model panes — the `pane_grid` spacing and the visible
|
||||
/// divider width. The renderer derives tile rects through this same spacing so
|
||||
/// the drawn viewports line up exactly with the pane_grid layout.
|
||||
|
|
@ -1488,6 +1502,10 @@ struct SceneLight {
|
|||
|
||||
pub struct Scene {
|
||||
pub camera: Rc<RefCell<Camera>>,
|
||||
/// View saved immediately before the latest navigation operation. ZOOM
|
||||
/// Previous swaps with this snapshot, allowing the user to toggle between
|
||||
/// the two most recent views without touching drawing history.
|
||||
previous_view: Option<ViewSnapshot>,
|
||||
/// Model-space tiled viewport layout. One full-window tile by default;
|
||||
/// the split buttons / VPORTS subdivide the active tile.
|
||||
pub(crate) model_tiles: RefCell<Vec<ModelTile>>,
|
||||
|
|
@ -1932,6 +1950,7 @@ impl Scene {
|
|||
pub fn new() -> Self {
|
||||
Self {
|
||||
camera: Rc::new(RefCell::new(Camera::default())),
|
||||
previous_view: None,
|
||||
model_tiles: RefCell::new(vec![ModelTile {
|
||||
rect: iced::Rectangle {
|
||||
x: 0.0,
|
||||
|
|
|
|||
Loading…
Reference in a new issue