1919
2020# python
2121import sqlite3
22- import os , argparse , sys
22+ import os , argparse , sys , json , zipfile
2323
2424from dataclasses import dataclass
2525from typing import Optional , Tuple
@@ -78,6 +78,10 @@ def get_arguments(argv=None):
7878 parser .add_argument ("-pvh" , "--height" , type = int , default = 800 , help = "Set plotter height" )
7979 parser .add_argument ("-pvx" , "--x" , type = int , default = 0 , help = "Set plotter x position" )
8080 parser .add_argument ("-pvy" , "--y" , type = int , default = 0 , help = "Set plotter y position" )
81+ parser .add_argument ("-pvvtk" , "--pyvista-vtksz" , default = None ,
82+ help = "Export PyVista scene as a VTK.js .vtksz file; .vtksz is added if omitted" )
83+ parser .add_argument ("-pvz" , "--pyvista-vtksz-zoom" , type = float , default = 0.25 ,
84+ help = "Initial VTK.js scene zoom for --pyvista-vtksz; smaller values zoom out" )
8185 parser .add_argument ("-axes" , "--add_axes_at_zero" , action = "store_true" ,
8286 help = "Add 10cm axes at (0, 0, 0)" )
8387
@@ -103,6 +107,7 @@ def __init__(
103107 args = None ,
104108 enable_pyvista : Optional [bool ] = None ,
105109 use_background_plotter : bool = None ,
110+ pyvista_vtksz : Optional [str ] = None ,
106111 ):
107112 self .args = get_arguments () # expose args to scripts that use this class
108113 self .experiment = experiment
@@ -133,12 +138,21 @@ def __init__(
133138 # pyvista
134139 # CLI background flag
135140 background_flag = bool (getattr (self .args , "pyvista_background" , False ))
141+ self .pyvista_vtksz = pyvista_vtksz if pyvista_vtksz is not None else getattr (
142+ self .args , "pyvista_vtksz" , None
143+ )
144+ self .pyvista_vtksz_zoom = getattr (self .args , "pyvista_vtksz_zoom" , 0.25 )
145+ self .show_pyvista_window = (
146+ self .args .pyvista
147+ or background_flag
148+ or (enable_pyvista is True and not self .pyvista_vtksz )
149+ )
136150
137151 # Decide if PyVista is wanted:
138152 # - programmatic enable_pyvista overrides CLI (True/False)
139- # - otherwise: --pyvista OR --pvb imply pyvista
153+ # - otherwise: --pyvista, --pvb, or --pyvista-vtksz imply pyvista
140154 if enable_pyvista is None :
141- wants_pyvista = self .args .pyvista or background_flag
155+ wants_pyvista = self .args .pyvista or background_flag or bool ( self . pyvista_vtksz )
142156 else :
143157 wants_pyvista = enable_pyvista
144158
@@ -159,6 +173,7 @@ def __init__(
159173
160174 self ._plotter : Optional [object ] = None
161175 self ._camera_initialized = False
176+ self ._pyvista_vtksz_exported = False
162177
163178 # Set the initial variation and file names.
164179 #
@@ -225,6 +240,92 @@ def close(self):
225240 self ._plotter .close ()
226241 self ._plotter = None
227242
243+ def export_vtksz (self , filename : Optional [str ] = None , zoom : Optional [float ] = None ):
244+ """Export the current PyVista scene as a VTK.js OfflineLocalView file."""
245+ if not self .use_pyvista :
246+ return None
247+
248+ output = filename if filename is not None else self .pyvista_vtksz
249+ if not output :
250+ return None
251+ if not output .endswith (".vtksz" ):
252+ output = f"{ output } .vtksz"
253+
254+ p = self .plotter
255+ if p is None :
256+ return None
257+
258+ self ._configure_camera_from_bounds ()
259+ try :
260+ p .camera .view_angle = 70.0
261+ except Exception :
262+ pass
263+ try :
264+ p .render ()
265+ except Exception :
266+ pass
267+
268+ try :
269+ argv = sys .argv
270+ sys .argv = [argv [0 ]]
271+ exported = p .export_vtksz (output )
272+ except ImportError as e :
273+ sys .exit (f"{ GColors .RED } Error exporting PyVista VTK.js scene: { e } { GColors .END } " )
274+ finally :
275+ sys .argv = argv
276+
277+ output_zoom = self .pyvista_vtksz_zoom if zoom is None else zoom
278+ self ._adjust_vtksz_camera (exported , zoom = output_zoom )
279+
280+ self ._pyvista_vtksz_exported = True
281+ print (f" ❖ Exported PyVista VTK.js scene: { exported } " )
282+ return exported
283+
284+ def _adjust_vtksz_camera (self , filename , zoom : float = 0.25 ):
285+ if zoom <= 0 :
286+ return
287+
288+ try :
289+ with zipfile .ZipFile (filename , "r" ) as zf :
290+ entries = {name : zf .read (name ) for name in zf .namelist ()}
291+ except (OSError , zipfile .BadZipFile ):
292+ return
293+
294+ if "index.json" not in entries :
295+ return
296+
297+ try :
298+ data = json .loads (entries ["index.json" ].decode ("utf-8" ))
299+ except (UnicodeDecodeError , json .JSONDecodeError ):
300+ return
301+
302+ def adjust_node (node ):
303+ if not isinstance (node , dict ):
304+ return
305+
306+ if "Camera" in str (node .get ("type" , "" )):
307+ properties = node .get ("properties" , {})
308+ position = properties .get ("position" )
309+ focal_point = properties .get ("focalPoint" )
310+ if position and focal_point and len (position ) == 3 and len (focal_point ) == 3 :
311+ properties ["position" ] = [
312+ focal_point [i ] + (position [i ] - focal_point [i ]) / zoom
313+ for i in range (3 )
314+ ]
315+ properties ["viewAngle" ] = 45.0
316+ properties ["parallelProjection" ] = False
317+ properties .pop ("parallelScale" , None )
318+
319+ for child in node .get ("dependencies" , []):
320+ adjust_node (child )
321+
322+ adjust_node (data .get ("scene" ))
323+ entries ["index.json" ] = json .dumps (data ).encode ("utf-8" )
324+
325+ with zipfile .ZipFile (filename , "w" , compression = zipfile .ZIP_DEFLATED ) as zf :
326+ for name , content in entries .items ():
327+ zf .writestr (name , content )
328+
228329
229330 def ascii_storage_files (self ):
230331 """
@@ -389,6 +490,9 @@ def show(self, block: bool = True):
389490 except AttributeError :
390491 pass # BackgroundPlotter may not expose ren_win directly
391492
493+ if self .pyvista_vtksz and not self ._pyvista_vtksz_exported :
494+ self .export_vtksz (self .pyvista_vtksz )
495+
392496 if self .use_background_plotter :
393497 # BackgroundPlotter path
394498 if block and hasattr (p , "app" ):
@@ -408,7 +512,7 @@ def show(self, block: bool = True):
408512 )
409513
410514
411- elif block :
515+ elif block and self . show_pyvista_window :
412516 p .show ()
413517
414518 def _configure_camera_from_bounds (self , margin : float = 0.8 , distance_scale : float = 4.0 ):
@@ -496,6 +600,7 @@ def autogeometry(
496600 auto_show : bool = True ,
497601 enable_pyvista : Optional [bool ] = None ,
498602 use_background_plotter : Optional [bool ] = None ,
603+ pyvista_vtksz : Optional [str ] = None ,
499604):
500605 # in jupyter: always enable pyvista, never use atexit
501606 if _in_jupyter :
@@ -507,6 +612,7 @@ def autogeometry(
507612 application ,
508613 enable_pyvista = enable_pyvista ,
509614 use_background_plotter = use_background_plotter ,
615+ pyvista_vtksz = pyvista_vtksz ,
510616 )
511617
512618 if auto_show :
0 commit comments