rcsb_viewer_hf / app.py
Klaatu's picture
app
9800ce8
"""
RCSB Disulfide Bond Database Browser
Author: Eric G. Suchanek, PhD
Last revision: 2025-02-23 16:41:02 -egs-
"""
# pylint: disable=C0301 # line too long
# pylint: disable=C0413 # wrong import order
# pylint: disable=C0103 # wrong variable name
# pylint: disable=W0212 # access to a protected member _render of a client class
# pylint: disable=W0602 # Using global for variable
# pylint: disable=W0612 # unused variable
# pylint: disable=W0613 # unused argument
# pylint: disable=W0603 # Using global for variable
import logging
import os
from datetime import datetime
from pathlib import Path
import numpy as np
import panel as pn
import param
import pyvista as pv
from proteusPy import (
BOND_RADIUS,
WINSIZE,
Disulfide,
DisulfideList,
DisulfideLoader,
DisulfideVisualization,
Load_PDB_SS,
__version__,
create_logger,
get_jet_colormap,
get_theme,
grid_dimensions,
)
from proteusPy.ProteusGlobals import DATA_DIR
proteus_vers = __version__
_vers = "0.99.1"
# Set PyVista to use offscreen rendering if the environment variable is set
# needed in Docker.
pv.OFF_SCREEN = True
if os.getenv("PYVISTA_OFF_SCREEN", "false").lower() == "true":
pv.OFF_SCREEN = True
pn.extension("vtk", sizing_mode="stretch_width", template="fast")
_logger = create_logger("rcsb_viewer", log_level=logging.INFO)
# configure_master_logger("rcsb_viewer.log")
_logger.info("Starting RCSB Disulfide Viewer v%s, proteusPy v%s", _vers, proteus_vers)
current_theme = get_theme()
_logger.debug("Current Theme: %s", current_theme)
# defaults for the UI
_rcsid_default = "2q7q"
_default_ss = "2q7q_75D_140D"
_ssidlist = [
"2q7q_75D_140D",
"2q7q_81D_113D",
"2q7q_88D_171D",
"2q7q_90D_138D",
"2q7q_91D_135D",
"2q7q_98D_129D",
"2q7q_130D_161D",
]
_rcsid = "2q7q"
_style = "Split Bonds"
_single = True
# Set up logging
_logger = create_logger("rcsb_viewer", log_level=logging.INFO)
# globals
ss_state = {}
RCSB_list = []
PDB_SS = None
HOME = Path.home()
SAVE_PATH = HOME / "Pictures" / "proteusPy"
SAVE_PATH.mkdir(parents=True, exist_ok=True)
_logger.debug("Save Path: %s", SAVE_PATH)
# default selections
ss_state_default = {
"single": True, # corrected Boolean type
"style": "Split Bonds",
"rcsb_list": ["2q7q"],
"rcsid": "2q7q",
"defaultss": "2q7q_75D_140D",
"ssid_list": [
"2q7q_75D_140D",
"2q7q_81D_113D",
"2q7q_88D_171D",
"2q7q_90D_138D",
"2q7q_91D_135D",
"2q7q_98D_129D",
],
"theme": current_theme,
"view_mode": "Single View", # added default for view_selector
}
# Widgets
styles_group = pn.widgets.RadioBoxGroup(
name="Rending Style",
options=["Split Bonds", "CPK", "Ball and Stick"],
inline=False,
)
rcsb_ss_widget = pn.widgets.Select(
name="Disulfide", value=_default_ss, options=_ssidlist
)
rcsb_selector_widget = pn.widgets.AutocompleteInput(
name="RCSB ID (start typing)",
value=_rcsid_default,
restrict=True,
placeholder="Search Here",
options=RCSB_list,
)
# controls on sidebar
ss_props = pn.WidgetBox(
"## Disulfide Selection", rcsb_selector_widget, rcsb_ss_widget
).servable(target="sidebar")
# Replace single checkbox with a selector widget
view_selector = pn.widgets.Select(
name="View Mode",
options=[
"Single View",
"Multiview",
"List",
"Overlay",
],
value="Single View",
)
# Modify the layout
ss_styles = pn.WidgetBox("## Display Style", styles_group, view_selector).servable(
target="sidebar"
)
# Adjust the update_single function to handle the different view options
def update_view(click):
"""
Adjust the rendering style radio box depending on the state of the view mode selector.
Returns:
None
"""
global styles_group
_logger.debug("Update View")
selected_view = view_selector.value
match selected_view:
case "Single View":
styles_group.disabled = False
case "Multiview":
styles_group.disabled = True
case "List":
styles_group.disabled = False
case "Overlay":
styles_group.disabled = True
# set_state()
click_plot(click)
# Update references to `single_checkbox` to `view_selector` in callbacks
view_selector.param.watch(update_view, "value")
# markdown panels for various text outputs
title_md = pn.pane.Markdown("Title", dedent=True, renderer="markdown")
output_md = pn.pane.Markdown("Output goes here", dedent=True, renderer="markdown")
db_md = pn.pane.Markdown("Database Info goes here", dedent=True, renderer="markdown")
info_md = pn.pane.Markdown("SS Info")
ss_info = pn.WidgetBox("## Disulfide Info", info_md).servable(target="sidebar")
def set_window_title():
"""
Sets the window title using values from the global loader.
"""
tot = PDB_SS.TotalDisulfides
pdbs = len(PDB_SS.SSDict)
vers = PDB_SS.version
win_title = f"RCSB Disulfide Browser v{_vers}, SS: {tot:,}, Structures: {pdbs:,}, DB: {vers}, proteusPy: {proteus_vers}"
pn.state.template.param.update(title=win_title)
mess = f"Set Window Title: {win_title}"
_logger.debug(mess)
def set_widgets_defaults():
"""
Set the default values for the widgets and ensure the RCSB list is populated.
This function sets the default values for the rendering style, single view checkbox,
and RCSB selector widget. It also ensures that the RCSB list is correctly populated
from the loaded data if it is not already populated.
Globals:
RCSB_list (list): A global list of RCSB IDs.
PDB_SS (object): A global object containing the loaded PDB data.
Returns:
dict: The default state dictionary for the widgets.
"""
global RCSB_list
_logger.debug("Setting widget defaults.")
styles_group.value = "Split Bonds"
# Ensure the RCSB list is correctly populated from loaded data
if not RCSB_list:
RCSB_list = sorted(
PDB_SS.IDList
) # Load PDB IDs into RCSB_list if not populated
rcsb_selector_widget.options = RCSB_list
rcsb_selector_widget.value = ss_state_default["rcsid"]
rcsb_ss_widget.value = ss_state_default["defaultss"]
return ss_state_default
def set_state(event=None):
"""Set the state of the application based on the current widget values."""
global ss_state
ss_state = {
"rcsb_list": RCSB_list.copy(),
"rcsid": rcsb_selector_widget.value,
"ssid_list": _ssidlist.copy(),
"style": styles_group.value,
"defaultss": rcsb_ss_widget.value,
"theme": get_theme(),
"view_mode": view_selector.value, # Added view mode state
}
pn.state.cache["ss_state"] = ss_state
_logger.info("Set state: %s", ss_state["view_mode"])
click_plot(None)
def set_widgets_from_state():
"""Set the widgets based on the state cache."""
global ss_state
if "ss_state" in pn.state.cache:
ss_state = pn.state.cache["ss_state"]
_logger.debug("Setting widgets from state.")
rcsb_selector_widget.value = ss_state["rcsid"]
rcsb_ss_widget.value = ss_state["defaultss"]
styles_group.value = ss_state["style"]
view_selector.value = ss_state["view_mode"] # Set view mode from cache
else:
# Fallback to default values if cache is empty
set_widgets_defaults()
def plot(pl, ss, style="sb", light=True, panelsize=512, verbose=True) -> pv.Plotter:
"""
Return the pyVista Plotter object for the Disulfide bond in the specific rendering style.
:param single: Display the bond in a single panel in the specific style.
:param style: Rendering style: One of:
* 'sb' - split bonds
* 'bs' - ball and stick
* 'cpk' - CPK style
* 'pd' - Proximal/Distal style - Red=proximal, Green=Distal
* 'plain' - boring single color
:param light: If True, light background, if False, dark
"""
global plotter, vtkpan
_logger.debug("Entering plot: %s", ss.name)
mode = view_selector.value
if light:
pv.set_plot_theme("document")
else:
pv.set_plot_theme("dark")
if mode == "Single View":
_logger.debug("Single View")
plotter = pv.Plotter(window_size=WINSIZE)
plotter.clear()
DisulfideVisualization._render_ss(ss, plotter, style=style)
elif mode == "Multiview":
_logger.debug("Multiview")
plotter = pv.Plotter(shape=(2, 2), window_size=WINSIZE)
plotter.clear()
plotter.subplot(0, 0)
DisulfideVisualization._render_ss(ss, plotter, style="cpk")
plotter.subplot(0, 1)
DisulfideVisualization._render_ss(ss, plotter, style="bs")
plotter.subplot(1, 0)
DisulfideVisualization._render_ss(ss, plotter, style="sb")
plotter.subplot(1, 1)
DisulfideVisualization._render_ss(ss, plotter, style="pd")
plotter.link_views()
elif mode == "List":
_logger.debug("List")
pdbid = ss.pdb_id
sslist = PDB_SS[pdbid]
if verbose:
sslist.quiet = False
tot_ss = len(sslist) # number off ssbonds
rows, cols = grid_dimensions(tot_ss)
winsize = (panelsize * cols, panelsize * rows)
plotter = pv.Plotter(window_size=winsize, shape=(rows, cols))
plotter.clear()
plotter = DisulfideVisualization._render_sslist(plotter, sslist, style=style)
plotter.enable_anti_aliasing("msaa")
plotter.link_views()
update_sslist_info(sslist)
else:
plotter = pv.Plotter(window_size=WINSIZE)
plotter.clear()
_logger.info("Overlay")
pdbid = ss.pdb_id
sslist = PDB_SS[pdbid]
plotter = render_overlay(sslist, plotter, verbose=verbose)
update_sslist_info(sslist)
plotter.reset_camera()
return plotter
def is_running_in_docker():
"""Check if the application is running inside a Docker container."""
return os.getenv("DOCKER_RUNNING", "false").lower() == "true"
@pn.cache()
def load_data():
"""Load the RCSB Disulfide Database and return the object."""
global RCSB_list, PDB_SS
_logger.info("Loading RCSB Disulfide Database")
# Determine the loadpath based on the environment
if is_running_in_docker():
loadpath = "/app/data"
else:
loadpath = DATA_DIR
_logger.info("Loading RCSB Disulfide Database from: %s", loadpath) # noqa
PDB_SS = Load_PDB_SS(verbose=True, subset=False, loadpath=loadpath)
RCSB_list = sorted(PDB_SS.IDList)
_logger.info("Loaded RCSB Disulfide Database: %d entries", len(RCSB_list))
set_window_title()
pn.state.cache["data"] = PDB_SS
return PDB_SS
if "data" in pn.state.cache:
PDB_SS = pn.state.cache["data"]
else:
PDB_SS = load_data()
set_window_title()
set_widgets_defaults()
def get_panel_theme() -> str:
"""Return the current theme: 'default' or 'dark'
Returns:
str: The current theme
"""
return pn.config.theme
def click_plot(event):
"""Force a re-render of the currently selected disulfide. Reuses the existing plotter."""
# Reuse the existing plotter and re-render the scene
global plotter
# Store current view mode before render
current_mode = view_selector.value
plotter = render_ss() # Reuse the existing plotter
plotter.reset_camera()
# If we're in List/Overlay mode, ensure list info stays displayed
if current_mode in ["List", "Overlay"]:
ss_id = rcsb_selector_widget.value
sslist = PDB_SS[ss_id]
if sslist:
update_sslist_info(sslist)
# Update the vtkpan and trigger a refresh
vtkpan.object = plotter.ren_win
# Callbacks
def get_ss_idlist(event) -> list:
"""Determine the list of disulfides for the given RCSB entry and
update the RCSB_ss_widget appropriately.
Returns:
List of SS Ids
"""
rcs_id = event.new
sslist = DisulfideList([], "tmp")
sslist = PDB_SS[rcs_id]
idlist = []
if sslist:
idlist = [ss.name for ss in sslist]
rcsb_ss_widget.options = idlist
mess = f"get_ss_idlist |{rcs_id}| |{idlist}|"
_logger.debug(mess)
return idlist
def update_title(ss):
"""Update the title of the disulfide bond in the markdown pane."""
name = ss.name
title = f"## {name}"
title_md.object = title
def update_sslist_info(sslist: DisulfideList) -> str:
"""
Prints out relevant attributes of the given disulfideList.
:param disulfideList: A list of disulfide objects.
:param list_name: The name of the list.
"""
name = sslist.pdb_id
avg_distance = sslist.average_ca_distance
avg_sg_distance = sslist.average_sg_distance
avg_energy = sslist.average_energy
avg_resolution = sslist.average_resolution
list_length = len(sslist.data)
dev_df = sslist.create_deviation_dataframe()
stats = dev_df.describe()
ca_std = stats.loc["std", "Ca_Distance"]
sg_std = stats.loc["std", "Sg_Distance"]
angle_std = stats.loc["std", "Angle_Deviation"]
bond_std = stats.loc["std", "Bondlength_Deviation"]
if list_length == 0:
avg_bondangle = 0
avg_bondlength = 0
else:
total_bondangle = 0
total_bondlength = 0
for ss in sslist.data:
total_bondangle += ss.bond_angle_ideality
total_bondlength += ss.bond_length_ideality
avg_bondangle = total_bondangle / list_length
avg_bondlength = total_bondlength / list_length
info_string = f"""
### {name} Disulfides
**Total Disulfides:** {list_length}
**Resolution:** {avg_resolution:.2f} Γ…
**Energy:** {avg_energy:.2f} kcal/mol
**CΞ± distance:** {avg_distance:.2f} Β± {ca_std:.2f} Γ…
**Sg distance:** {avg_sg_distance:.2f} Β± {sg_std:.2f} Γ…
**Bond Angle Deviation:** {avg_bondangle:.2f} Β± {angle_std:.2f}Β°
**Bond Length Deviation:** {avg_bondlength:.2f} Β± {bond_std:.2f} Γ…
"""
info_md.object = info_string
return info_string
def update_info(ss: Disulfide) -> str:
"""Update the information of the disulfide bond in the markdown pane."""
info_string = f"""
### {ss.name}
**Resolution:** {ss.resolution:.2f} Γ…
**Energy:** {ss.energy:.2f} kcal/mol
**CΞ± distance:** {ss.ca_distance:.2f} Γ…
**SΞ³ distance:** {ss.sg_distance:.2f} Γ…
**Torsion Length:** {ss.torsion_length:.2f}Β°
**Rho:** {ss.rho:.2f}Β°
**Secondary:** {ss.proximal_secondary} / {ss.distal_secondary}
"""
info_md.object = info_string
return info_string
def update_db_info(pdb: DisulfideLoader) -> None:
"""Update the information of the disulfide bond in the markdown pane."""
tot = pdb.TotalDisulfides
pdbs = len(pdb.SSDict)
vers = pdb.version
res = pdb.SSList.average_resolution
info_string = f"""**Version:** {vers}
**Structures:** {pdbs}
**Average Resolution:** {res:.2f} Γ…
**Total Disulfides:** {tot}
"""
db_md.object = info_string
def update_output(ss: Disulfide) -> None:
"""Update the output of the disulfide bond in the markdown pane."""
info_string = f"""**CΞ±-CΞ±:** {ss.ca_distance:.2f} Γ…
**CΞ²-CΞ²:** {ss.cb_distance:.2f} Γ…
**Torsion Length:** {ss.torsion_length:.2f}Β°
**Resolution:** {ss.resolution:.2f} Γ…
**Energy:** {ss.energy:.2f} kcal/mol
"""
output_md.object = info_string
def get_ss(event) -> Disulfide:
"""Get the currently selected Disulfide"""
ss_id = event.new
ss = Disulfide(PDB_SS[ss_id])
return ss
def get_ss_id(event):
"""Return the name of the currently selected Disulfide"""
rcsb_ss_widget.value = event.new
set_state(event)
def render_ss():
"""
Render the currently selected disulfide with the current plotter.
"""
global plotter
light = True
styles = {"Split Bonds": "sb", "CPK": "cpk", "Ball and Stick": "bs"}
# Determine the theme
theme = get_panel_theme()
if theme == "dark":
light = False
# Retrieve the selected disulfide
ss_id = rcsb_ss_widget.value
ss = PDB_SS[ss_id]
if ss is None:
update_output(f"Cannot find ss_id {ss_id}! Returning!")
return
# Only update single SS info if not in List/Overlay mode
mode = view_selector.value
if mode not in ["List", "Overlay"]:
update_title(ss)
update_info(ss)
update_output(ss)
update_db_info(PDB_SS)
# Reuse and clear the existing plotter before rendering
style = styles[styles_group.value]
# Render the structure in the plotter
return plot(plotter, ss, style=style, light=light)
def on_theme_change(event):
"""
Callback function to handle theme changes.
"""
global ss_state
ss_state = pn.state.cache["ss_state"]
new_theme = event.new
ss_state["theme"] = new_theme
pn.state.cache["ss_state"] = ss_state
_logger.debug("Theme changed to: %s", new_theme)
if new_theme == "dark":
plotter.set_background("black")
else:
plotter.set_background("white")
plotter.render()
vtkpan.object = plotter.ren_win # Update the VTK pane object
def render_overlay(
sslist: DisulfideList,
pl: pv.Plotter,
verbose=False,
light="Auto",
):
"""
Display all disulfides in the list overlaid in stick mode against
a common coordinate frames. This allows us to see all of the disulfides
at one time in a single view. Colors vary smoothy between bonds.
:param screenshot: Save a screenshot, defaults to False
:param movie: Save a movie, defaults to False
:param verbose: Verbosity, defaults to True
:param fname: Filename to save for the movie or screenshot, defaults to 'ss_overlay.png'
:param light: Background color, defaults to True for White. False for Dark.
"""
# pl = DisulfideVisualization.display_overlay(sslist, pl=pl, verbose=verbose)
# return pl
ssbonds = sslist.data
tot_ss = len(ssbonds) # number off ssbonds
res = 64
if tot_ss > 50:
res = 16
elif tot_ss > 40:
res = 24
elif tot_ss > 30:
res = 32
pl.clear()
pl.enable_anti_aliasing("msaa")
pl.add_axes()
mycol = np.zeros(shape=(tot_ss, 3))
mycol = get_jet_colormap(tot_ss)
# scale the overlay bond radii down so that we can see the individual elements better
# maximum 90% reduction
brad = BOND_RADIUS if tot_ss < 10 else BOND_RADIUS * 0.75
brad = brad if tot_ss < 25 else brad * 0.8
brad = brad if tot_ss < 50 else brad * 0.7
brad = brad if tot_ss < 100 else brad * 0.5
# center_of_mass = sslist.center_of_mass
for i, ss in zip(range(tot_ss), ssbonds):
color = [int(mycol[i][0]), int(mycol[i][1]), int(mycol[i][2])]
DisulfideVisualization._render_ss(
ss, pl, style="plain", bondcolor=color, res=res
)
pl.reset_camera()
return pl
class ReloadableApp(param.Parameterized):
"""
A class to handle programmatically reloading the Panel app.
This class uses a hidden HTML pane to inject JavaScript that reloads the page
when the `reload_trigger` parameter is incremented. It provides a method to
force a reload and a method to make the hidden pane servable.
:param reload_trigger: A parameter that triggers the reload when incremented.
:type reload_trigger: param.Integer
:param reload_pane: A hidden HTML pane used to inject the reload script.
:type reload_pane: pn.pane.HTML
Methods
-------
update_reload_script(event):
Updates the reload pane with a JavaScript reload script.
force_reload():
Increments the reload_trigger to force a page reload.
servable():
Returns the hidden reload pane to include in the layout.
"""
reload_trigger = param.Integer(default=0)
def __init__(self, **params):
super().__init__(**params)
self.reload_pane = pn.pane.HTML("", width=0, height=0, visible=False)
self.param.watch(self.update_reload_script, "reload_trigger")
def update_reload_script(self, event):
"""
Updates the reload pane with a JavaScript reload script.
:param event: The event that triggers the update.
:type event: param.parameterized.Event
"""
if event.new > 0:
self.reload_pane.object = "<script>window.location.reload();</script>"
def force_reload(self):
"""
Increments the reload_trigger to force a page reload.
"""
self.reload_trigger += 1
def servable(self):
"""
Returns the hidden reload pane to include in the layout.
:return: The hidden reload pane.
:rtype: pn.pane.HTML
"""
return self.reload_pane
# Instantiate ReloadableApp
reloadable_app = ReloadableApp()
def trigger_reload(event=None):
"""Force a page reload by incrementing the reload_trigger parameter."""
_logger.debug("Reloading the page.")
reloadable_app.force_reload()
# Create a Reload button
reload_button = pn.widgets.Button(name="Reload Page", button_type="primary")
# Bind the reload_button to trigger_reload function
reload_button.on_click(lambda event: trigger_reload())
# Add the button to your layout (e.g., in the sidebar or main area)
# Here, we'll add it to the sidebar alongside existing widgets
ss_props.append(reload_button)
rcsb_selector_widget.param.watch(get_ss_idlist, "value")
rcsb_ss_widget.param.watch(set_state, "value")
view_selector.param.watch(set_state, "value")
styles_group.param.watch(set_state, "value")
plotter = pv.Plotter(off_screen=True)
plotter = render_ss()
vtkpan = pn.pane.VTK(
plotter.ren_win,
margin=0,
sizing_mode="stretch_both",
orientation_widget=True,
enable_keybindings=True,
min_height=500,
)
pn.bind(get_ss_idlist, rcs_id=rcsb_selector_widget)
# Set window title and initialize widgets from cache or defaults
# Define the menu items
file_items = [("Save", "save")]
help_items = [("About", "about"), ("Documentation", "documentation")]
# Define the handler function
def handle_file_menu(event):
"""Handle the file menu items."""
_logger.info("File menu item selected: %s", event) # noqa
item = event
_logger.info("Selected item: %s", item)
if item == "save":
_logger.info("Save file dialog")
save_as_file()
# Add your code to handle saving a file
# Create the menu buttons and connect the handler
file_menu_button = pn.widgets.MenuButton(
name="File", icon="file", items=file_items, width=75, button_type="light"
)
help_menu_button = pn.widgets.MenuButton(
name="πŸ§πŸ»β€β™‚οΈ Help", items=help_items, width=125, button_type="light"
)
def show_about_dialog():
"""Shows the about dialog."""
return pn.pane.Alert(
"""
RCSB Disulfide Bond Database Browser
Version: 0.99.1
"""
)
def show_documentation_dialog():
"""Shows the documentation dialog."""
return pn.pane.Alert(
"""
Documentation
"""
)
def handle_help_menu(event):
"""Handle the help menu items."""
_logger.info("Help menu item selected: %s", event) # noqa
item = event
if item == "about":
_logger.info("Show about dialog")
show_about_dialog()
# Add your code to show the about dialog
elif item == "documentation":
show_documentation_dialog()
_logger.info("Show documentation")
# Create the menus
menu = pn.Row(
file_menu_button,
help_menu_button,
styles={"border-bottom": "1px solid black"},
)
file_menu_button.on_click(lambda event: handle_file_menu("save"))
help_menu_button.on_click(lambda event: handle_help_menu(event.new))
# the only way i can get the handler to work is to bind it to the button directly like this
# we don't actually use the fmenu or hmenu columns
fmenu = pn.Column(
file_menu_button,
pn.bind(handle_file_menu, file_menu_button.param.clicked),
)
hmenu = pn.Column(
help_menu_button,
pn.bind(handle_help_menu, help_menu_button.param.clicked),
)
def create_filename(pdbid, disulfide=None, mode=None):
"""
Create a filename based on the pdbid, specific disulfide selected, mode, and a date and timestamp.
:param pdbid: The PDB ID.
:param disulfide: The specific disulfide selected (optional).
:param mode: The mode (optional).
:return: The generated filename.
"""
# Get the current date and time
now = datetime.now()
timestamp = now.strftime("%Y%m%d_%H%M%S")
# Base filename with pdbid and timestamp
filename = f"{pdbid}_{timestamp}"
# Append disulfide information if mode is not LIST or OVERLAY
if mode not in ["LIST", "OVERLAY"] and disulfide:
filename = f"{pdbid}_{disulfide}_{timestamp}"
else:
filename = f"{pdbid}_{timestamp}"
# Add file extension
filename += ".png"
return filename
# Function to save the current window state to a file
def save_as_file():
"""Save the current window state to a file."""
global ss_state
fname = create_filename(
ss_state["rcsid"], ss_state["defaultss"], ss_state["view_mode"]
)
screenshot_path = SAVE_PATH / fname
_logger.info("Saving the current view to file %s", str(screenshot_path))
try:
plotter.add_light(
pv.Light(position=(10, 10, 10), focal_point=(0, 0, 0), intensity=1.0)
)
plotter.add_light(
pv.Light(position=(-10, -10, 10), focal_point=(0, 0, 0), intensity=0.5)
)
plotter.screenshot(str(screenshot_path))
_logger.info("Saved screenshot to %s", screenshot_path)
except (OSError, ValueError) as e:
_logger.error("Failed to save file: %s, error: %s", screenshot_path, e)
set_window_title()
set_widgets_from_state()
app = pn.Column(vtkpan, reloadable_app.servable())
app.servable()
# end of file