diff --git a/src/e3sm_quickview/app.py b/src/e3sm_quickview/app.py index d0c1d7a..606a16d 100644 --- a/src/e3sm_quickview/app.py +++ b/src/e3sm_quickview/app.py @@ -173,6 +173,7 @@ def _build_ui(self, **_): ProjectionEquidistant="projection = ['Cyl. Equidistant']", ProjectionRobinson="projection = ['Robinson']", ProjectionMollweide="projection = ['Mollweide']", + ProjectionSpherical="projection = ['Spherical']", FileOpen=(self.toggle_toolbar, "['load-data']"), SaveState="trigger('download_state_dialog')", UploadState="utils.get('document').querySelector('#fileUpload').click()", @@ -196,6 +197,7 @@ def _build_ui(self, **_): mt.bind("c", "ProjectionEquidistant") mt.bind("r", "ProjectionRobinson") mt.bind("m", "ProjectionMollweide") + mt.bind("n", "ProjectionSpherical") mt.bind("f", "FileOpen") mt.bind("e", "SaveState") @@ -648,12 +650,16 @@ async def _on_projection(self, projection, **_): self.source.UpdatePipeline() self.view_manager.reset_camera() - # Hack to force reset_camera for "cyl mode" - # => may not be needed if we switch to rca - if " " in proj_str: - for _ in range(2): - await asyncio.sleep(0.1) - self.view_manager.reset_camera() + @change("spherical_center_lat", "spherical_center_lon", "projection") + def _on_center(self, spherical_center_lat, spherical_center_lon, projection, **_): + if projection == ["Spherical"]: + self.source.Clip(self.view_manager._clip_plane) + self.view_manager.center_camera( + float(spherical_center_lat), float(spherical_center_lon) + ) + else: + self.source.Clip() + self.view_manager.camera_projection() def _on_slicing_change(self, var, ind_var, **_): with perf.timed(f"tick.{var}={self.state[ind_var]}.total"): diff --git a/src/e3sm_quickview/components/doc.py b/src/e3sm_quickview/components/doc.py index ab297b5..ccaee06 100644 --- a/src/e3sm_quickview/components/doc.py +++ b/src/e3sm_quickview/components/doc.py @@ -1,7 +1,6 @@ from trame.widgets import html from trame.widgets import vuetify3 as v3 -from e3sm_quickview.assets import ASSETS # ----------------------------------------------------------------------------- # Tools @@ -72,12 +71,13 @@ def __init__(self): super().__init__( icon="mdi-earth", title="Map Projection", - description="Select projection to use for the visualizations: cylindrical equidistant, Robinson, Mollweide.", + description="Select projection to use for the visualizations: cylindrical equidistant, Robinson, Mollweide, Spherical.", ) with self, v3.Template(v_slot_append=True): v3.VHotkey(keys="c", variant="contained", inline=True) v3.VHotkey(keys="r", variant="contained", inline=True) v3.VHotkey(keys="m", variant="contained", inline=True) + v3.VHotkey(keys="n", variant="contained", inline=True) class ToolLayoutManagement(Tool): diff --git a/src/e3sm_quickview/components/toolbars.py b/src/e3sm_quickview/components/toolbars.py index 56781e6..ae3feb0 100644 --- a/src/e3sm_quickview/components/toolbars.py +++ b/src/e3sm_quickview/components/toolbars.py @@ -26,11 +26,60 @@ def __init__( self.state.setdefault("show_zoom_controls", False) self.state.setdefault("show_pan_controls", False) self.state.setdefault("show_aspect_ratio", False) + self.state.setdefault("show_spherical_center", False) with self: v3.VIcon("mdi-view-module", classes="px-6 opacity-50") v3.VSpacer() with html.Div(classes="d-flex ga-2 align-center"): + # --- Spherical projection center --- + with v3.VSheet( + classes="d-flex align-center rounded px-1 ga-1 py-1", + color=("show_spherical_center ? 'grey-lighten-3' : 'transparent'",), + v_if="projection[0] === 'Spherical'", + ): + v3.VIconBtn( + v_tooltip_bottom="'Toggle Spherical center'", + icon="mdi-target", + flat=True, + click="show_spherical_center = !show_spherical_center", + color=("show_spherical_center ? 'primary' : ''",), + size=("show_spherical_center ? 'small' : 'default'",), + classes=("show_spherical_center ? 'ml-1' : 'rounded'",), + ) + with ( + v3.VExpandXTransition(), + html.Div( + v_if="show_spherical_center", + classes="d-flex align-center ga-1", + ), + ): + v3.VDivider(vertical=True, classes="mx-1") + v3.VNumberInput( + label="Latitude", + v_model=("spherical_center_lat", 0), + precision=1, + min=("-90",), + max=("90",), + step=("0.5",), + hide_details=True, + density="compact", + variant="plain", + style="min-width: 10rem;", + ) + v3.VNumberInput( + label="Longitude", + v_model=("spherical_center_lon", 0), + precision=1, + min=("-180",), + max=("180",), + step=("0.5",), + hide_details=True, + density="compact", + variant="plain", + style="min-width: 10rem;", + ) + # --- Aspect ratio toggle + slider --- with v3.VSheet( classes="d-flex align-center rounded px-1 ga-1 py-1", diff --git a/src/e3sm_quickview/components/tools.py b/src/e3sm_quickview/components/tools.py index 0c0b6b0..3cf8ae7 100644 --- a/src/e3sm_quickview/components/tools.py +++ b/src/e3sm_quickview/components/tools.py @@ -264,6 +264,11 @@ def options(self): "value": "Mollweide", "key": "m", }, + { + "title": "Spherical", + "value": "Spherical", + "key": "n", + }, ] diff --git a/src/e3sm_quickview/pipeline.py b/src/e3sm_quickview/pipeline.py index 6c098d3..99be6e9 100644 --- a/src/e3sm_quickview/pipeline.py +++ b/src/e3sm_quickview/pipeline.py @@ -89,6 +89,9 @@ def projection(self, value): self._projection = value self.proj.Projection = value + def update(self): + self.geometry.UpdatePipeline() + class GridLines: def __init__(self, projection="Mollweide"): @@ -123,6 +126,10 @@ def projection(self, value): self._projection = value self.proj.Projection = value + def update(self): + self.geometry.UpdatePipeline() + self.mapper.Update() + class DataReader: def __init__(self, projection="Mollweide"): @@ -286,6 +293,9 @@ def UpdateProjection(self, proj): self.data_reader.projection = proj self.grid_lines.projection = proj self.continent.projection = proj + self.data_reader.update() + self.grid_lines.update() + self.continent.update() def UpdatePipeline(self, time=0.0): self.data_reader.update(time) @@ -308,6 +318,14 @@ def LoadVariables(self, vars): self.data_reader.reader.Variables = list(set([*vars, "lat", "lon"])) + def Clip(self, plane=None): + self.grid_lines.mapper.RemoveAllClippingPlanes() + self.continent.mapper.RemoveAllClippingPlanes() + + if plane: + self.grid_lines.mapper.AddClippingPlane(plane) + self.continent.mapper.AddClippingPlane(plane) + if __name__ == "__main__": e = EAMVisSource() diff --git a/src/e3sm_quickview/plugins/eam_projection.py b/src/e3sm_quickview/plugins/eam_projection.py index be80485..1175bd1 100644 --- a/src/e3sm_quickview/plugins/eam_projection.py +++ b/src/e3sm_quickview/plugins/eam_projection.py @@ -35,6 +35,7 @@ from vtkmodules.util import numpy_support, vtkConstants from vtkmodules.util.vtkAlgorithm import VTKPythonAlgorithmBase + # Number of threads for the projection fan-out. pyproj releases the GIL # inside Transformer.transform, so chunking the input across threads # scales nearly linearly (7.4x on 8 threads in our bench). Default is @@ -75,6 +76,7 @@ def work(i): y_out = np.concatenate([r[1] for r in results]) return x_out, y_out + try: import warnings @@ -204,7 +206,7 @@ def add_cell_arrays(inData, outData, cached_output): out_np = numpy_support.vtk_to_numpy(out_array) for s, e in zip(starts, ends): src_off = int(pid_np[s]) - out_np[s:e] = in_np[src_off:src_off + (e - s)] + out_np[s:e] = in_np[src_off : src_off + (e - s)] out_array.Modified() @@ -381,6 +383,7 @@ def RequestData(self, request, inInfo, outInfo): + """ @@ -446,26 +449,42 @@ def RequestData(self, request, inInfo, outInfo): x = flat[0::3] - 180.0 if self.translate else flat[0::3] y = flat[1::3] - try: - # Use proj4 string for WGS84 instead of EPSG code to avoid database dependency - latlon = Proj(proj="latlong", datum="WGS84") - if self.project == 1: - proj = Proj(proj="robin") - elif self.project == 2: - proj = Proj(proj="moll") - else: - # Should not reach here, but return without transformation + if self.project == 3: + # Spherical + to_rad = np.pi / 180 + x_rad = x * to_rad + y_rad = y * to_rad + cos_y_rad = np.cos(y_rad) + xs = np.cos(x_rad) * cos_y_rad + zs = np.sin(x_rad) * cos_y_rad + ys = np.sin(y_rad) + flat[0::3] = xs + flat[1::3] = ys + flat[2::3] = zs + else: + try: + # Use proj4 string for WGS84 instead of EPSG code to avoid database dependency + latlon = Proj(proj="latlong", datum="WGS84") + if self.project == 1: + proj = Proj(proj="robin") + elif self.project == 2: + proj = Proj(proj="moll") + else: + # Should not reach here, but return without transformation + return 1 + + xformer = Transformer.from_proj( + latlon, proj, always_xy=True + ) + with _perf.timed("project.pyproj_transform"): + res = _threaded_transform(xformer, x, y) + except Exception as e: + print(f"Projection error: {e}") + # If projection fails, return without modifying coordinates return 1 - xformer = Transformer.from_proj(latlon, proj, always_xy=True) - with _perf.timed("project.pyproj_transform"): - res = _threaded_transform(xformer, x, y) - except Exception as e: - print(f"Projection error: {e}") - # If projection fails, return without modifying coordinates - return 1 - flat[0::3] = np.array(res[0]) - flat[1::3] = np.array(res[1]) + flat[0::3] = np.array(res[0]) + flat[1::3] = np.array(res[1]) outPoints = flat.reshape(out_points_np.shape) _coords = numpy_support.numpy_to_vtk(outPoints, deep=True) @@ -473,7 +492,9 @@ def RequestData(self, request, inInfo, outInfo): # the previous cached_points, if any, is available for # garbage collection after this assignment self.cached_points = out_points_vtk - self._cached_input_points = in_points # hold ref so id() stays valid + self._cached_input_points = ( + in_points # hold ref so id() stays valid + ) self._cached_key = cache_key return 1 diff --git a/src/e3sm_quickview/view_manager.py b/src/e3sm_quickview/view_manager.py index 174dd10..0146ef8 100644 --- a/src/e3sm_quickview/view_manager.py +++ b/src/e3sm_quickview/view_manager.py @@ -12,6 +12,7 @@ from trame.ui.html import DivLayout from trame.widgets import client, colormaps, rca from trame.widgets import vuetify3 as v3 +from vtkmodules.vtkCommonDataModel import vtkPlane from vtkmodules.vtkRenderingCore import ( vtkCamera, vtkCellPicker, @@ -56,6 +57,7 @@ def __init__(self, server, source): super().__init__(server) self.use_image_stream = True self._camera = vtkCamera(parallel_projection=1) + self._clip_plane = vtkPlane() self._render_window = vtkRenderWindow() self._render_window.OffScreenRenderingOn() self._picker = vtkCellPicker(tolerance=0.0005) @@ -160,6 +162,28 @@ def refresh_ui(self, **_): for view in self._var2view.values(): view._build_ui() + def camera_projection(self): + self._camera.focal_point = (0, 0, 0) + self._camera.position = (0, 0, 1) + self._camera.view_up = (0, 1, 0) + self.reset_camera() + + def center_camera(self, lat, lon): + x = math.cos(lon * math.pi / 180) * math.cos(lat * math.pi / 180) + z = math.sin(lon * math.pi / 180) * math.cos(lat * math.pi / 180) + y = math.sin(lat * math.pi / 180) + self._camera.focal_point = (0, 0, 0) + self._camera.position = (x, y, z) + self._camera.view_up = ( + -math.cos(lon * math.pi / 180), + 2, + -math.sin(lon * math.pi / 180), + ) + + self._clip_plane.origin = (0, 0, 0) + self._clip_plane.normal = (x, y, z) + self.reset_camera() + def reset_camera(self, render=True): if self.layout_dirty or not self._last_vars: self.pending_reset_camera = 1 diff --git a/src/e3sm_quickview/view_panel.py b/src/e3sm_quickview/view_panel.py index 66f4aed..9b065bc 100644 --- a/src/e3sm_quickview/view_panel.py +++ b/src/e3sm_quickview/view_panel.py @@ -82,6 +82,7 @@ def bounds(self, v): def reset_camera(self): self.renderer.ResetCameraScreenSpace(0.9) + self.renderer.ResetCameraClippingRange() def update_size(self, size): new_size = (int(size["w"] * size["p"]), int(size["h"] * size["p"]))