From fc4247a3fe03bff31458ef0802d6250d31696d30 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Wed, 20 Aug 2025 00:08:11 -0700 Subject: [PATCH 1/2] fix: camera reset logic to be less complicated --- quickview/ui/projection_selection.py | 4 +- quickview/ui/slice_selection.py | 4 + quickview/utils/math.py | 65 ------------- quickview/view_manager.py | 138 ++++++++------------------- 4 files changed, 46 insertions(+), 165 deletions(-) diff --git a/quickview/ui/projection_selection.py b/quickview/ui/projection_selection.py index 34a9567..85ebbaf 100644 --- a/quickview/ui/projection_selection.py +++ b/quickview/ui/projection_selection.py @@ -45,5 +45,7 @@ def update_pipeline_interactive(self, **kwargs): projection = self.state.projection self.source.UpdateProjection(projection) self.source.UpdatePipeline() - self.views.update_views_for_timestep() + # For projection changes, we need to fit viewports to new bounds + self.views.update_views_for_timestep(fit_viewport=True) + # Render once after all updates self.views.render_all_views() diff --git a/quickview/ui/slice_selection.py b/quickview/ui/slice_selection.py index 53cbfed..149113c 100644 --- a/quickview/ui/slice_selection.py +++ b/quickview/ui/slice_selection.py @@ -235,11 +235,15 @@ def update_pipeline_interactive(self, **kwargs): tstamp = self.state.tstamp long = self.state.cliplong lat = self.state.cliplat + self.source.UpdateLev(lev, ilev) self.source.UpdateTimeStep(tstamp) self.source.ApplyClipping(long, lat) self.source.UpdatePipeline() + + # update_views_for_timestep will handle fitting and rendering self.views.update_views_for_timestep() + # Render once after all updates self.views.render_all_views() def on_click_advance_middle(self, diff): diff --git a/quickview/utils/math.py b/quickview/utils/math.py index 29c450c..d856290 100644 --- a/quickview/utils/math.py +++ b/quickview/utils/math.py @@ -39,71 +39,6 @@ def calculate_weighted_average( return np.mean(data) -def calculate_aspect_ratio_scale( - bounds: List[float], viewport_size: Tuple[float, float], margin: float = 1.05 -) -> Optional[float]: - """ - Calculate optimal ParallelScale for ParaView camera based on data bounds and viewport. - - This function determines the appropriate camera scale to fit objects within - the viewport while maintaining aspect ratio. - - Args: - bounds: Data bounds [xmin, xmax, ymin, ymax, zmin, zmax] - viewport_size: Viewport dimensions (width, height) in pixels - margin: Margin factor (1.05 = 5% margin around objects) - - Returns: - Optimal parallel scale value for the camera, or None if calculation fails - """ - if not bounds or len(bounds) < 4: - return None - - # Calculate data dimensions - width = bounds[1] - bounds[0] - height = bounds[3] - bounds[2] - - if width <= 0 or height <= 0: - return None - - if viewport_size[0] <= 0 or viewport_size[1] <= 0: - return None - - viewport_aspect = viewport_size[0] / viewport_size[1] - data_aspect = width / height - - # Calculate optimal parallel scale - # The parallel scale represents half the height of the view in world coordinates - if data_aspect > viewport_aspect: - # Data is wider than viewport - fit to width - parallel_scale = (width / (2.0 * viewport_aspect)) * margin - else: - # Data is taller than viewport - fit to height - parallel_scale = (height / 2.0) * margin - - return parallel_scale - - -def calculate_data_center(bounds: List[float]) -> List[float]: - """ - Calculate the center point of data bounds. - - Args: - bounds: Data bounds [xmin, xmax, ymin, ymax, zmin, zmax] - - Returns: - Center point [x, y, z] - """ - if not bounds or len(bounds) < 6: - return [0, 0, 0] - - return [ - (bounds[0] + bounds[1]) / 2, - (bounds[2] + bounds[3]) / 2, - (bounds[4] + bounds[5]) / 2, - ] - - def calculate_data_range(bounds: List[float]) -> Tuple[float, float, float]: """ Calculate the range (width, height, depth) from data bounds. diff --git a/quickview/view_manager.py b/quickview/view_manager.py index db55d64..c6056ed 100644 --- a/quickview/view_manager.py +++ b/quickview/view_manager.py @@ -16,16 +16,11 @@ from quickview.pipeline import EAMVisSource from quickview.utils.color import get_cached_colorbar_image from quickview.utils.geometry import generate_annotations as generate_map_annotations -from quickview.utils.math import ( - calculate_weighted_average, - calculate_aspect_ratio_scale, - calculate_data_center, -) +from quickview.utils.math import calculate_weighted_average from quickview.utils.state import ViewContext, ViewRegistry # Constants for camera and display LABEL_OFFSET_FACTOR = 0.075 # Factor for offsetting labels from map edge -CAMERA_Z_OFFSET = 1000 # Z-axis offset for 2D camera positioning ZOOM_IN_FACTOR = 0.95 # Scale factor for zooming in ZOOM_OUT_FACTOR = 1.05 # Scale factor for zooming out DEFAULT_MARGIN = 1.05 # Default margin for viewport fitting (5% margin) @@ -103,11 +98,19 @@ def get_color_range(self, var, index): else: return self.compute_range(var) - def update_views_for_timestep(self): + def update_views_for_timestep(self, fit_viewport=True): + """Update views for timestep changes. + + Args: + fit_viewport: Whether to fit viewport after update (default True). + Set to False to avoid redundant fits when caller will do it. + """ if len(self.registry) == 0: return data = sm.Fetch(self.source.views["atmosphere_data"]) + first_view = None + for var, context in self.registry.items(): varavg = self.compute_average(var, vtkdata=data) # Directly set average in trame state @@ -128,9 +131,14 @@ def update_views_for_timestep(self): self.state.dirty("varmax") self.generate_colorbar_image(context.index) - # Fit objects optimally after geometry changes - if context.view_proxy: - self.fit_to_viewport(context.view_proxy) + + # Track the first view for camera fitting + if first_view is None and context.view_proxy: + first_view = context.view_proxy + + # Only fit the first view since cameras are linked + if fit_viewport and first_view: + self.fit_to_viewport(first_view) def refresh_view_display(self, context: ViewContext): if not self.should_use_manual_range(context.index): @@ -204,8 +212,8 @@ def configure_new_view(self, var, context: ViewContext, sources): rep.SetScalarBarVisibility(rview, False) rview.CameraParallelProjection = 1 + # Skip individual fit - will be handled by view0 after all views are created Render(rview) - # ResetCamera(rview) # This function is no longer needed - we work directly with state arrays @@ -244,103 +252,33 @@ def generate_colorbar_image(self, index): except Exception as e: print(f"Error getting cached colorbar image for {var}: {e}") - def calculate_parallel_scale(self, view, margin=DEFAULT_MARGIN): - """ - Calculate optimal ParallelScale for a view based on GridProj bounds. - - Args: - view: The render view to calculate scale for - margin: Margin factor (1.05 = 5% margin around objects) - - Returns: - Optimal parallel scale value, or None if calculation fails - """ - from paraview.simple import UpdatePipeline, FindSource - - try: - # Ensure pipeline is up to date - UpdatePipeline() - - # Get GridProj bounds - it encompasses the full map extent - grid_source = FindSource("GridProj") - if not grid_source: - return None - - bounds = grid_source.GetDataInformation().GetBounds() - if not bounds or bounds[0] > bounds[1]: - return None - - # Use the utility function for calculation - view_size = view.ViewSize - return calculate_aspect_ratio_scale(bounds, view_size, margin) - - except Exception as e: - print(f"Error calculating parallel scale: {e}") - return None - - def fit_to_viewport(self, view, margin=DEFAULT_MARGIN): + def fit_to_viewport(self, view, margin=DEFAULT_MARGIN, use_largest_viewport=False): """ - Dynamically calculate and set optimal ParallelScale to fit objects in viewport. + Reset camera to fit objects in viewport. Args: view: The render view to fit - margin: Margin factor (1.05 = 5% margin around objects) + margin: Not used (kept for compatibility) + use_largest_viewport: Not used (kept for compatibility) """ - from paraview.simple import SetActiveView, FindSource + from paraview.simple import SetActiveView try: - # Set this as the active view to ensure camera operations work correctly + # Set this view as active and reset camera SetActiveView(view) - - # Calculate the optimal parallel scale - parallel_scale = self.calculate_parallel_scale(view, margin) - if parallel_scale is None: - return - - # Get GridProj bounds for centering the camera - grid_source = FindSource("GridProj") - if not grid_source: - return - - combined_bounds = grid_source.GetDataInformation().GetBounds() - if not combined_bounds: - return - - # Get the view's camera directly - camera = view.GetActiveCamera() - camera.SetParallelProjection(True) - camera.SetParallelScale(parallel_scale) - - # Center camera on data using utility function - center = calculate_data_center(combined_bounds) - camera.SetFocalPoint(*center) - - # For 2D projections, position camera perpendicular to XY plane - camera_pos = [center[0], center[1], center[2] + CAMERA_Z_OFFSET] - camera.SetPosition(*camera_pos) - camera.SetViewUp(0, 1, 0) - - # Apply the camera settings to the view - view.CameraParallelScale = parallel_scale + view.ResetCamera(True, 0.9) except Exception as e: print(f"Error in fit_to_viewport: {e}") - # Fallback to simple reset if our calculation fails - try: - from paraview.simple import ResetCamera - - ResetCamera(view) - except Exception: - pass def reset_camera(self, **kwargs): """Reset camera for all views to optimally fit objects.""" - for i, widget in enumerate(self.widgets): - if i < len(self.state.variables): - var = self.state.variables[i] - context = self.registry.get_view(var) - if context and context.view_proxy: - self.fit_to_viewport(context.view_proxy) + # Only reset the first view since cameras are linked + if len(self.widgets) > 0 and len(self.state.variables) > 0: + var = self.state.variables[0] + context = self.registry.get_view(var) + if context and context.view_proxy: + self.fit_to_viewport(context.view_proxy) self.render_all_views() def render_all_views(self, **kwargs): @@ -493,6 +431,7 @@ def rebuild_visualization_layout(self, cached_layout=None): self.configure_new_view(var, context, self.source.views) else: self.refresh_view_display(context) + # Skip individual viewport fitting - will be done once for view0 else: view = CreateRenderView() view.UseColorPaletteForBackground = 0 @@ -579,23 +518,24 @@ def rebuild_visualization_layout(self, cached_layout=None): # Use index as identifier to maintain compatibility with grid expectations layout.append({"x": x, "y": y, "w": wdt, "h": hgt, "i": index}) - view0.CameraParallelScale = 100 + # Only fit view0 since all cameras are linked + if view0 is not None: + self.fit_to_viewport(view0) self.state.views = sWidgets self.state.layout = layout self.state.dirty("views") self.state.dirty("layout") - # from trame.app import asynchronous - # asynchronous.create_task(self.flushViews()) + + # Single render after all updates + self.render_all_views() """ async def flushViews(self): await self.server.network_completion - print("Flushing views") self.render_all_views() import asyncio await asyncio.sleep(1) - print("Resetting views after sleep") self.render_all_views() """ From ca0fedfdc324a0352792f3cbce753b389ba93d51 Mon Sep 17 00:00:00 2001 From: Abhishek Yenpure Date: Wed, 20 Aug 2025 00:09:16 -0700 Subject: [PATCH 2/2] =?UTF-8?q?Bump=20version:=200.1.13=20=E2=86=92=200.1.?= =?UTF-8?q?14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- quickview/__init__.py | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 7d49d1e..21431ac 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.1.13 +current_version = 0.1.14 commit = True tag = True diff --git a/pyproject.toml b/pyproject.toml index 80b6d09..24e4082 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "quickview" -version = "0.1.13" +version = "0.1.14" description = "An application to explore/analyze data for atmosphere component for E3SM" authors = [ {name = "Kitware Inc."}, diff --git a/quickview/__init__.py b/quickview/__init__.py index d84fb00..3b115ed 100644 --- a/quickview/__init__.py +++ b/quickview/__init__.py @@ -1,5 +1,5 @@ """QuickView: Visual Analysis for E3SM Atmosphere Data.""" -__version__ = "0.1.13" +__version__ = "0.1.14" __author__ = "Kitware Inc." __license__ = "Apache-2.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 28828cd..6f4f9fb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "0.1.13" +version = "0.1.14" description = "QuickView: Visual Analyis for E3SM Atmosphere Data" authors = ["Kitware"] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0b070d4..1229771 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -7,7 +7,7 @@ }, "package": { "productName": "QuickView", - "version": "0.1.13" + "version": "0.1.14" }, "tauri": { "allowlist": {