From 22fec9d2855629dc061ed1f07a13c115bd511aa7 Mon Sep 17 00:00:00 2001 From: Patrick O'Leary Date: Mon, 4 May 2026 08:38:11 -0500 Subject: [PATCH] feat: add pan and zoom toggle controls with full camera state save/restore - Add zoom(factor) and pan(dx, dy) methods to both view managers - Add aspect ratio, zoom, and pan toggle groups to Layout toolbar with background highlight - Add reset view button with render fix - Save/restore full camera state (zoom, position, focal point, view up, clipping range) - Streamline toolbar: remove label, icon-only grouped/size buttons with tooltips - Aspect ratio slider with 0.25 step ticks (0-4 range) --- src/e3sm_quickview/app.py | 11 +- src/e3sm_quickview/components/toolbars.py | 153 ++++++++++++++++------ src/e3sm_quickview/view_manager.py | 50 ++++++- src/e3sm_quickview/view_manager2.py | 47 +++++-- 4 files changed, 205 insertions(+), 56 deletions(-) diff --git a/src/e3sm_quickview/app.py b/src/e3sm_quickview/app.py index 3d90b2e..e49f3be 100644 --- a/src/e3sm_quickview/app.py +++ b/src/e3sm_quickview/app.py @@ -224,8 +224,9 @@ def _build_ui(self, **_): with html.Div(style=css.TOOLBARS_FIXED_OVERLAY): toolbars.Layout( apply_size=self.view_manager.apply_size, - zoom_in=self.view_manager.zoom_in, - zoom_out=self.view_manager.zoom_out, + zoom=self.view_manager.zoom, + pan=self.view_manager.pan, + reset_camera=self.view_manager.reset_camera, ) toolbars.Cropping() toolbars.DataSelection() @@ -307,7 +308,7 @@ def download_state(self): "active": self.state.active_layout, "tools": self.state.active_tools, "help": not self.state.compact_drawer, - "zoom": self.view_manager.get_zoom(), + "camera": self.view_manager.get_camera_state(), } data_selection = { k: self.state[k] @@ -415,8 +416,8 @@ async def _import_state(self, state_content): self.state.active_layout = state_content["layout"]["active"] self.state.active_tools = state_content["layout"]["tools"] self.state.compact_drawer = not state_content["layout"]["help"] - if "zoom" in state_content["layout"]: - self.view_manager.set_zoom(state_content["layout"]["zoom"]) + if "camera" in state_content["layout"]: + self.view_manager.set_camera_state(state_content["layout"]["camera"]) # Update filebrowser state with self.state: diff --git a/src/e3sm_quickview/components/toolbars.py b/src/e3sm_quickview/components/toolbars.py index b9c4099..908aee4 100644 --- a/src/e3sm_quickview/components/toolbars.py +++ b/src/e3sm_quickview/components/toolbars.py @@ -38,65 +38,144 @@ def to_kwargs(value): class Layout(v3.VToolbar): - def __init__(self, apply_size=None, zoom_in=None, zoom_out=None): + def __init__( + self, + apply_size=None, + zoom=None, + pan=None, + reset_camera=None, + ): super().__init__(**to_kwargs("adjust-layout")) + self.state.setdefault("show_zoom_controls", False) + self.state.setdefault("show_pan_controls", False) + self.state.setdefault("show_aspect_ratio", False) + with self: v3.VIcon("mdi-view-module", classes="px-6 opacity-50") - v3.VLabel("Viewport layout", classes="text-subtitle-2") v3.VSpacer() + # --- Aspect ratio toggle + slider --- + with v3.VSheet( + classes="d-flex align-center rounded px-1", + color=("show_aspect_ratio ? 'grey-lighten-3' : 'transparent'",), + ): + v3.VIconBtn( + v_tooltip_bottom="'Toggle aspect ratio'", + icon="mdi-arrow-expand-vertical", + flat=True, + click="show_aspect_ratio = !show_aspect_ratio; show_zoom_controls = false; show_pan_controls = false", + color=("show_aspect_ratio ? 'primary' : ''",), + ) + v3.VSlider( + v_if="show_aspect_ratio", + v_tooltip_bottom="'Reduce (left) / Increase (right) vertical aspect'", + v_model=("aspect_ratio", 0.5), + min=0, + max=4, + step=0.25, + show_ticks="always", + density="compact", + hide_details=True, + style="min-width: 200px; max-width: 300px;", + ) + + # --- Zoom toggle + in/out --- + with v3.VSheet( + classes="d-flex align-center rounded px-1", + color=("show_zoom_controls ? 'grey-lighten-3' : 'transparent'",), + ): + v3.VIconBtn( + v_tooltip_bottom="'Toggle zoom controls'", + icon="mdi-magnify", + flat=True, + click="show_zoom_controls = !show_zoom_controls; show_pan_controls = false; show_aspect_ratio = false", + color=("show_zoom_controls ? 'primary' : ''",), + ) + v3.VIconBtn( + v_if="show_zoom_controls", + v_tooltip_bottom="'Zoom in'", + icon="mdi-plus", + flat=True, + click=lambda: zoom(1 / 1.2), + ) + v3.VIconBtn( + v_if="show_zoom_controls", + v_tooltip_bottom="'Zoom out'", + icon="mdi-minus", + flat=True, + click=lambda: zoom(1.2), + ) + + # --- Pan toggle + directions --- + with v3.VSheet( + classes="d-flex align-center rounded px-1", + color=("show_pan_controls ? 'grey-lighten-3' : 'transparent'",), + ): + v3.VIconBtn( + v_tooltip_bottom="'Toggle pan controls'", + icon="mdi-arrow-all", + flat=True, + click="show_pan_controls = !show_pan_controls; show_zoom_controls = false; show_aspect_ratio = false", + color=("show_pan_controls ? 'primary' : ''",), + ) + v3.VIconBtn( + v_if="show_pan_controls", + v_tooltip_bottom="'Pan up'", + icon="mdi-arrow-up", + flat=True, + click=lambda: pan(0, -1), + ) + v3.VIconBtn( + v_if="show_pan_controls", + v_tooltip_bottom="'Pan down'", + icon="mdi-arrow-down", + flat=True, + click=lambda: pan(0, 1), + ) + v3.VIconBtn( + v_if="show_pan_controls", + v_tooltip_bottom="'Pan left'", + icon="mdi-arrow-left", + flat=True, + click=lambda: pan(1, 0), + ) + v3.VIconBtn( + v_if="show_pan_controls", + v_tooltip_bottom="'Pan right'", + icon="mdi-arrow-right", + flat=True, + click=lambda: pan(-1, 0), + ) + + # --- Reset view --- v3.VIconBtn( - v_tooltip_bottom="'Zoom in'", - icon="mdi-magnify-plus-outline", - flat=True, - click=zoom_in, - ) - v3.VIconBtn( - v_tooltip_bottom="'Zoom out'", - icon="mdi-magnify-minus-outline", + v_tooltip_bottom="'Reset view'", + icon="mdi-fit-to-page-outline", flat=True, - click=zoom_out, + click=lambda: reset_camera(), ) - v3.VSlider( - v_model=("aspect_ratio", 0.5), - prepend_icon="mdi-arrow-expand-vertical", - min=0.25, - max=2, - step=0.05, - density="compact", - hide_details=True, - style="max-width: 400px;", - ) - v3.VSpacer() + v3.VDivider(vertical=True, classes="mx-1") - # ------------------------------------------------------------ - # Add tooltip for keyboard shortcut?? - # ------------------------------------------------------------ - # with v3.VTooltip(location="bottom"): - # with v3.Template(v_slot_activator="{ props }"): - v3.VHotkey(keys="g", variant="contained", classes="mr-1") + # --- Grouped/Uniform toggle --- v3.VCheckbox( - # v_bind="props", + v_tooltip_bottom="layout_grouped ? 'Switch to uniform' : 'Switch to grouped'", v_model=("layout_grouped", True), - label=("layout_grouped ? 'Grouped' : 'Uniform'",), hide_details=True, inset=True, false_icon="mdi-apps", true_icon="mdi-focus-field", density="compact", ) - # with html.Span("Keyboard shortcut"): - # v3.VHotkey(theme="dark", keys="g", variant="contained", inline=True, classes="ml-2 mt-n2") - # ------------------------------------------------------------ + # --- Size menu --- with v3.VBtn( - "Size", - classes="text-none mx-4", - prepend_icon="mdi-view-column", - append_icon="mdi-menu-down", + v_tooltip_bottom="'Column layout'", + flat=True, + size="small", ): + v3.VIcon("mdi-view-column") with v3.VMenu(activator="parent"): with v3.VList(density="compact"): with v3.VListItem( diff --git a/src/e3sm_quickview/view_manager.py b/src/e3sm_quickview/view_manager.py index bc8ed6e..f181d2f 100644 --- a/src/e3sm_quickview/view_manager.py +++ b/src/e3sm_quickview/view_manager.py @@ -1019,16 +1019,13 @@ def reset_camera(self): for view in views: view.disable_render = False - def zoom_in(self): - for view in list(self._var2view.values()): - cam = view.camera - cam.SetParallelScale(cam.GetParallelScale() / 1.2) + for view in views: view.render() - def zoom_out(self): + def zoom(self, factor): for view in list(self._var2view.values()): cam = view.camera - cam.SetParallelScale(cam.GetParallelScale() * 1.2) + cam.SetParallelScale(cam.GetParallelScale() * factor) view.render() def get_zoom(self): @@ -1043,6 +1040,47 @@ def set_zoom(self, scale): view.camera.SetParallelScale(scale) view.render() + def pan(self, dx, dy): + for view in list(self._var2view.values()): + cam = view.camera + scale = cam.GetParallelScale() + step = scale * 0.1 + pos = list(cam.GetPosition()) + foc = list(cam.GetFocalPoint()) + pos[0] += dx * step + pos[1] += dy * step + foc[0] += dx * step + foc[1] += dy * step + cam.SetPosition(*pos) + cam.SetFocalPoint(*foc) + view.render() + + def get_camera_state(self): + for view in list(self._var2view.values()): + cam = view.camera + return { + "zoom": cam.GetParallelScale(), + "position": list(cam.GetPosition()), + "focal_point": list(cam.GetFocalPoint()), + "view_up": list(cam.GetViewUp()), + "clipping_range": list(cam.GetClippingRange()), + } + return None + + def set_camera_state(self, camera_state): + if camera_state is None: + return + for view in list(self._var2view.values()): + cam = view.camera + cam.SetParallelScale(camera_state["zoom"]) + cam.SetPosition(*camera_state["position"]) + cam.SetFocalPoint(*camera_state["focal_point"]) + if "view_up" in camera_state: + cam.SetViewUp(*camera_state["view_up"]) + if "clipping_range" in camera_state: + cam.SetClippingRange(*camera_state["clipping_range"]) + view.render() + def render(self): for view in list(self._var2view.values()): view.render() diff --git a/src/e3sm_quickview/view_manager2.py b/src/e3sm_quickview/view_manager2.py index f576e2b..f09f3b9 100644 --- a/src/e3sm_quickview/view_manager2.py +++ b/src/e3sm_quickview/view_manager2.py @@ -1090,14 +1090,8 @@ def reset_camera(self, render=True): if render and view_to_reset: self.render() - def zoom_in(self): - scale = self._camera.GetParallelScale() - self._camera.SetParallelScale(scale / 1.2) - self.render() - - def zoom_out(self): - scale = self._camera.GetParallelScale() - self._camera.SetParallelScale(scale * 1.2) + def zoom(self, factor): + self._camera.SetParallelScale(self._camera.GetParallelScale() * factor) self.render() def get_zoom(self): @@ -1109,6 +1103,43 @@ def set_zoom(self, scale): self._camera.SetParallelScale(scale) self.render() + def pan(self, dx, dy): + cam = self._camera + scale = cam.GetParallelScale() + step = scale * 0.1 + pos = list(cam.GetPosition()) + foc = list(cam.GetFocalPoint()) + pos[0] += dx * step + pos[1] += dy * step + foc[0] += dx * step + foc[1] += dy * step + cam.SetPosition(*pos) + cam.SetFocalPoint(*foc) + self.render() + + def get_camera_state(self): + cam = self._camera + return { + "zoom": cam.GetParallelScale(), + "position": list(cam.GetPosition()), + "focal_point": list(cam.GetFocalPoint()), + "view_up": list(cam.GetViewUp()), + "clipping_range": list(cam.GetClippingRange()), + } + + def set_camera_state(self, camera_state): + if camera_state is None: + return + cam = self._camera + cam.SetParallelScale(camera_state["zoom"]) + cam.SetPosition(*camera_state["position"]) + cam.SetFocalPoint(*camera_state["focal_point"]) + if "view_up" in camera_state: + cam.SetViewUp(*camera_state["view_up"]) + if "clipping_range" in camera_state: + cam.SetClippingRange(*camera_state["clipping_range"]) + self.render() + @controller.set("size_update") def on_size_update(self): if not self.layout_dirty or not self.pending_render: