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"]))