openseespy-viewer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: openseespy-viewer
3
+ Version: 0.1.0
4
+ Summary: Live visual previewer for OpenSeesPy models
5
+ Author: Igor Barcelos
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/igor-barcelos/openseespy-viewer
8
+ Keywords: opensees,openseespy,structural,visualization,pyvista
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Education
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: numpy
18
+ Requires-Dist: pyvista
19
+ Requires-Dist: watchdog
20
+
21
+ # openseespy-viewer
22
+
23
+ Viewer for openseespy models. Monitors your model file(s) and produces a live 3D drawing that updates each time you save. Works with 2D/3dof and 3D/6dof models.
24
+
25
+ ### Installation
26
+
27
+ ```
28
+ pip install openseespy-viewer
29
+ ```
30
+
31
+ Or install from source:
32
+
33
+ ```
34
+ pip install .
35
+ ```
36
+
37
+ ### Usage
38
+
39
+ **Python API:**
40
+
41
+ ```python
42
+ from viewer import view
43
+
44
+ # Single file
45
+ view('my_model.py')
46
+
47
+ # Multiple files
48
+ view('nodes.py', 'elements.py', 'boundary.py')
49
+
50
+ # With custom style options
51
+ view('my_model.py', bg_colour='white', node_size=16, ele_width=3)
52
+ ```
53
+
54
+ **Command line:**
55
+
56
+ ```bash
57
+ # Basic usage
58
+ openseespy-viewer my_model.py
59
+
60
+ # Multiple files with custom refresh rate
61
+ openseespy-viewer nodes.py elements.py --refresh 1.0
62
+
63
+ # Or via python -m
64
+ python -m viewer my_model.py
65
+ ```
66
+
67
+ ### Screenshots
68
+
69
+ Press **S** in the viewer window to save a screenshot. Screenshots are saved to the `images/` folder with incrementing names (`model_screenshot_001.png`, `model_screenshot_002.png`, etc.). The counter resets each time you restart the viewer. This is useful for sharing your model with AI tools like Claude Code — just ask it to look at the screenshot.
70
+
71
+ ### Examples
72
+
73
+ ![Example usage gif](assets/demo_2.gif)
74
+
75
+
76
+ See the `examples/` directory for sample model files.
77
+
78
+ ### Dependencies
79
+
80
+ ```
81
+ pip install pyvista numpy watchdog
82
+ ```
83
+
84
+ ### Feature wish-list
85
+
86
+ - [x] Plot nodes as they are saved in a watched script
87
+ - [x] Add node labels
88
+ - [x] Add support for elements
89
+ - [x] Add support for fixity conditions
90
+ - [x] Add support for variables and expressions in scripts
91
+ - [x] Add support for watching multiple files (for models made up of many files)
92
+ - [ ] Add support for loads
93
+ - [ ] Add support for nodes created within loop
94
+ - [x] Add support for 3D models
95
+ - [ ] Add support for rotational dofs
96
+ - [x] Screenshot capture (press S in viewer)
@@ -0,0 +1,76 @@
1
+ # openseespy-viewer
2
+
3
+ Viewer for openseespy models. Monitors your model file(s) and produces a live 3D drawing that updates each time you save. Works with 2D/3dof and 3D/6dof models.
4
+
5
+ ### Installation
6
+
7
+ ```
8
+ pip install openseespy-viewer
9
+ ```
10
+
11
+ Or install from source:
12
+
13
+ ```
14
+ pip install .
15
+ ```
16
+
17
+ ### Usage
18
+
19
+ **Python API:**
20
+
21
+ ```python
22
+ from viewer import view
23
+
24
+ # Single file
25
+ view('my_model.py')
26
+
27
+ # Multiple files
28
+ view('nodes.py', 'elements.py', 'boundary.py')
29
+
30
+ # With custom style options
31
+ view('my_model.py', bg_colour='white', node_size=16, ele_width=3)
32
+ ```
33
+
34
+ **Command line:**
35
+
36
+ ```bash
37
+ # Basic usage
38
+ openseespy-viewer my_model.py
39
+
40
+ # Multiple files with custom refresh rate
41
+ openseespy-viewer nodes.py elements.py --refresh 1.0
42
+
43
+ # Or via python -m
44
+ python -m viewer my_model.py
45
+ ```
46
+
47
+ ### Screenshots
48
+
49
+ Press **S** in the viewer window to save a screenshot. Screenshots are saved to the `images/` folder with incrementing names (`model_screenshot_001.png`, `model_screenshot_002.png`, etc.). The counter resets each time you restart the viewer. This is useful for sharing your model with AI tools like Claude Code — just ask it to look at the screenshot.
50
+
51
+ ### Examples
52
+
53
+ ![Example usage gif](assets/demo_2.gif)
54
+
55
+
56
+ See the `examples/` directory for sample model files.
57
+
58
+ ### Dependencies
59
+
60
+ ```
61
+ pip install pyvista numpy watchdog
62
+ ```
63
+
64
+ ### Feature wish-list
65
+
66
+ - [x] Plot nodes as they are saved in a watched script
67
+ - [x] Add node labels
68
+ - [x] Add support for elements
69
+ - [x] Add support for fixity conditions
70
+ - [x] Add support for variables and expressions in scripts
71
+ - [x] Add support for watching multiple files (for models made up of many files)
72
+ - [ ] Add support for loads
73
+ - [ ] Add support for nodes created within loop
74
+ - [x] Add support for 3D models
75
+ - [ ] Add support for rotational dofs
76
+ - [x] Screenshot capture (press S in viewer)
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: openseespy-viewer
3
+ Version: 0.1.0
4
+ Summary: Live visual previewer for OpenSeesPy models
5
+ Author: Igor Barcelos
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/igor-barcelos/openseespy-viewer
8
+ Keywords: opensees,openseespy,structural,visualization,pyvista
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Education
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: numpy
18
+ Requires-Dist: pyvista
19
+ Requires-Dist: watchdog
20
+
21
+ # openseespy-viewer
22
+
23
+ Viewer for openseespy models. Monitors your model file(s) and produces a live 3D drawing that updates each time you save. Works with 2D/3dof and 3D/6dof models.
24
+
25
+ ### Installation
26
+
27
+ ```
28
+ pip install openseespy-viewer
29
+ ```
30
+
31
+ Or install from source:
32
+
33
+ ```
34
+ pip install .
35
+ ```
36
+
37
+ ### Usage
38
+
39
+ **Python API:**
40
+
41
+ ```python
42
+ from viewer import view
43
+
44
+ # Single file
45
+ view('my_model.py')
46
+
47
+ # Multiple files
48
+ view('nodes.py', 'elements.py', 'boundary.py')
49
+
50
+ # With custom style options
51
+ view('my_model.py', bg_colour='white', node_size=16, ele_width=3)
52
+ ```
53
+
54
+ **Command line:**
55
+
56
+ ```bash
57
+ # Basic usage
58
+ openseespy-viewer my_model.py
59
+
60
+ # Multiple files with custom refresh rate
61
+ openseespy-viewer nodes.py elements.py --refresh 1.0
62
+
63
+ # Or via python -m
64
+ python -m viewer my_model.py
65
+ ```
66
+
67
+ ### Screenshots
68
+
69
+ Press **S** in the viewer window to save a screenshot. Screenshots are saved to the `images/` folder with incrementing names (`model_screenshot_001.png`, `model_screenshot_002.png`, etc.). The counter resets each time you restart the viewer. This is useful for sharing your model with AI tools like Claude Code — just ask it to look at the screenshot.
70
+
71
+ ### Examples
72
+
73
+ ![Example usage gif](assets/demo_2.gif)
74
+
75
+
76
+ See the `examples/` directory for sample model files.
77
+
78
+ ### Dependencies
79
+
80
+ ```
81
+ pip install pyvista numpy watchdog
82
+ ```
83
+
84
+ ### Feature wish-list
85
+
86
+ - [x] Plot nodes as they are saved in a watched script
87
+ - [x] Add node labels
88
+ - [x] Add support for elements
89
+ - [x] Add support for fixity conditions
90
+ - [x] Add support for variables and expressions in scripts
91
+ - [x] Add support for watching multiple files (for models made up of many files)
92
+ - [ ] Add support for loads
93
+ - [ ] Add support for nodes created within loop
94
+ - [x] Add support for 3D models
95
+ - [ ] Add support for rotational dofs
96
+ - [x] Screenshot capture (press S in viewer)
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ openseespy_viewer.egg-info/PKG-INFO
4
+ openseespy_viewer.egg-info/SOURCES.txt
5
+ openseespy_viewer.egg-info/dependency_links.txt
6
+ openseespy_viewer.egg-info/entry_points.txt
7
+ openseespy_viewer.egg-info/requires.txt
8
+ openseespy_viewer.egg-info/top_level.txt
9
+ viewer/__init__.py
10
+ viewer/__main__.py
11
+ viewer/model.py
12
+ viewer/viewer.py
13
+ viewer/watcher.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ openseespy-viewer = viewer.__main__:main
@@ -0,0 +1,3 @@
1
+ numpy
2
+ pyvista
3
+ watchdog
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openseespy-viewer"
7
+ version = "0.1.0"
8
+ description = "Live visual previewer for OpenSeesPy models"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.8"
12
+ authors = [
13
+ { name = "Igor Barcelos" },
14
+ ]
15
+ keywords = ["opensees", "openseespy", "structural", "visualization", "pyvista"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Education",
19
+ "Intended Audience :: Science/Research",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Scientific/Engineering",
23
+ ]
24
+ dependencies = [
25
+ "numpy",
26
+ "pyvista",
27
+ "watchdog",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/igor-barcelos/openseespy-viewer"
32
+
33
+ [tool.setuptools.packages.find]
34
+ include = ["viewer*"]
35
+
36
+ [project.scripts]
37
+ openseespy-viewer = "viewer.__main__:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """viewer: Live visual previewer for OpenSeesPy models."""
2
+
3
+ from .viewer import view
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["view"]
@@ -0,0 +1,25 @@
1
+ """CLI entry point: python -m openseespy_viewer model1.py [model2.py ...]"""
2
+
3
+ import argparse
4
+ from .viewer import view
5
+
6
+
7
+ def main():
8
+ parser = argparse.ArgumentParser(
9
+ prog="openseespy-viewer",
10
+ description="Live visual previewer for OpenSeesPy models.",
11
+ )
12
+ parser.add_argument(
13
+ "modelfiles", nargs="+",
14
+ help="One or more OpenSeesPy model files (.py) to visualise.",
15
+ )
16
+ parser.add_argument(
17
+ "--refresh", type=float, default=0.5,
18
+ help="Minimum refresh interval in seconds (default: 0.5).",
19
+ )
20
+ args = parser.parse_args()
21
+ view(*args.modelfiles, min_refresh_interval=args.refresh)
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()
@@ -0,0 +1,73 @@
1
+ """Mock OpenSeesPy module for intercepting ops.node(), ops.element(), etc."""
2
+
3
+ import os
4
+ import sys
5
+ import types
6
+
7
+
8
+ class Model:
9
+ """A mock replacement for openseespy.opensees that records model data
10
+ instead of building a real OpenSees model.
11
+ """
12
+ def __init__(self):
13
+ self.nodes = []
14
+ self.elements = []
15
+ self.fixities = []
16
+ self.ndm = 2
17
+
18
+ def model(self, *args):
19
+ args = [str(a) for a in args]
20
+ if '-ndm' in args:
21
+ idx = args.index('-ndm')
22
+ self.ndm = int(args[idx + 1])
23
+
24
+ def node(self, tag, *coords):
25
+ self.nodes.append((int(tag),) + tuple(float(c) for c in coords))
26
+
27
+ def element(self, etype, tag, iNode, jNode, *rest):
28
+ self.elements.append((str(etype), int(tag), int(iNode), int(jNode)))
29
+
30
+ def fix(self, tag, *dofs):
31
+ self.fixities.append((int(tag),) + tuple(int(d) for d in dofs))
32
+
33
+ def __getattr__(self, name):
34
+ # Catch-all: silently ignore any other ops.xxx() call
35
+ return lambda *args, **kwargs: None
36
+
37
+
38
+ def parse_py(pyfiles):
39
+ """Parse OpenSeesPy (.py) files by mocking the openseespy module and
40
+ executing the user's script. All ops.node/element/fix calls are recorded.
41
+ Returns (nodes, elements, fixities, ndm).
42
+ """
43
+ mock = Model()
44
+
45
+ mock_package = types.ModuleType('openseespy')
46
+ mock_package.opensees = mock
47
+ mock_package.__path__ = []
48
+
49
+ saved = {}
50
+ for key in ('openseespy', 'openseespy.opensees'):
51
+ if key in sys.modules:
52
+ saved[key] = sys.modules[key]
53
+
54
+ sys.modules['openseespy'] = mock_package
55
+ sys.modules['openseespy.opensees'] = mock
56
+
57
+ try:
58
+ for pyfile in pyfiles:
59
+ filepath = os.path.abspath(pyfile)
60
+ with open(filepath, 'r') as f:
61
+ code = f.read()
62
+ exec(compile(code, filepath, 'exec'),
63
+ {'__name__': '__main__', '__file__': filepath})
64
+ except Exception as e:
65
+ print(f'Error parsing {pyfile}: {e}')
66
+ finally:
67
+ for key in ('openseespy', 'openseespy.opensees'):
68
+ if key in saved:
69
+ sys.modules[key] = saved[key]
70
+ elif key in sys.modules:
71
+ del sys.modules[key]
72
+
73
+ return mock.nodes, mock.elements, mock.fixities, mock.ndm
@@ -0,0 +1,224 @@
1
+ """Main viewer logic: builds the 3D scene and runs the live preview loop."""
2
+
3
+ import os
4
+ import sys
5
+ import numpy as np
6
+ import pyvista as pv
7
+
8
+ SCREENSHOT_DIR = os.path.join(os.getcwd(), 'images')
9
+ _screenshot_counter = 0
10
+
11
+ from .model import parse_py
12
+ from .watcher import start_watcher
13
+
14
+ # Default visual style
15
+ DEFAULTS = dict(
16
+ bg_colour='lightgrey',
17
+ node_colour='black',
18
+ node_size=12,
19
+ ele_colour='black',
20
+ ele_width=2,
21
+ axis_colour='grey',
22
+ label_font_size=10,
23
+ offset_ratio=0.02,
24
+ bc_arrow_scale=0.3,
25
+ min_refresh_interval=0.5,
26
+ )
27
+
28
+
29
+ def _nodecoords(nodetag, nodes, ndm):
30
+ """Returns node coordinates as a 3D point (z=0 for 2D models)."""
31
+ for node in nodes:
32
+ if node[0] == nodetag:
33
+ if ndm == 2:
34
+ return np.array([node[1], node[2], 0.0])
35
+ else:
36
+ return np.array([node[1], node[2], node[3]])
37
+ return None
38
+
39
+
40
+ def _build_scene(plotter, modelfiles, ndm, style):
41
+ """Parse model files and add all geometry to the plotter."""
42
+ nodes, elements, fixities, _ = parse_py(modelfiles)
43
+
44
+ if not nodes:
45
+ return
46
+
47
+ # Build node points array (always 3D; z=0 for 2D models)
48
+ if ndm == 2:
49
+ node_points = np.array([[n[1], n[2], 0.0] for n in nodes])
50
+ else:
51
+ node_points = np.array([[n[1], n[2], n[3]] for n in nodes])
52
+
53
+ node_tags = [n[0] for n in nodes]
54
+
55
+ # Compute model size for offsets
56
+ mins = node_points.min(axis=0)
57
+ maxs = node_points.max(axis=0)
58
+ spans = maxs - mins
59
+ model_size = max(spans.max(), 1.0)
60
+ offset = model_size * style['offset_ratio']
61
+
62
+ # --- Display nodes ---
63
+ node_cloud = pv.PolyData(node_points)
64
+ plotter.add_mesh(node_cloud, color=style['node_colour'],
65
+ point_size=style['node_size'],
66
+ render_points_as_spheres=True)
67
+
68
+ # Node labels
69
+ label_points = node_points + offset
70
+ node_labels = ['N' + str(t) for t in node_tags]
71
+ plotter.add_point_labels(label_points, node_labels,
72
+ font_size=style['label_font_size'],
73
+ text_color='black', font_family='courier',
74
+ bold=True, shape=None, fill_shape=False)
75
+
76
+ # --- Display reference axes ---
77
+ if ndm == 2:
78
+ margin = model_size * 0.15
79
+ x_line = pv.Line([mins[0] - margin, 0, 0], [maxs[0] + margin, 0, 0])
80
+ y_line = pv.Line([0, mins[1] - margin, 0], [0, maxs[1] + margin, 0])
81
+ plotter.add_mesh(x_line, color=style['axis_colour'], line_width=1,
82
+ style='wireframe')
83
+ plotter.add_mesh(y_line, color=style['axis_colour'], line_width=1,
84
+ style='wireframe')
85
+ else:
86
+ view_centre = (mins + maxs) / 2
87
+ view_range = spans.max()
88
+ axis_len = 1.2 * (view_range / 2 + max(abs(view_centre)))
89
+ x_axis = pv.Line([0, 0, 0], [axis_len, 0, 0])
90
+ y_axis = pv.Line([0, 0, 0], [0, axis_len, 0])
91
+ z_axis = pv.Line([0, 0, 0], [0, 0, axis_len])
92
+ plotter.add_mesh(x_axis, color='red', line_width=1)
93
+ plotter.add_mesh(y_axis, color='green', line_width=1)
94
+ plotter.add_mesh(z_axis, color='blue', line_width=1)
95
+
96
+ # --- Display elements ---
97
+ if elements:
98
+ for element in elements:
99
+ iNode = _nodecoords(element[2], nodes, ndm)
100
+ jNode = _nodecoords(element[3], nodes, ndm)
101
+ if iNode is not None and jNode is not None:
102
+ line = pv.Line(iNode, jNode)
103
+ plotter.add_mesh(line, color=style['ele_colour'],
104
+ line_width=style['ele_width'])
105
+ mid = (iNode + jNode) / 2 + offset
106
+ plotter.add_point_labels(
107
+ [mid], ['E' + str(element[1])],
108
+ font_size=style['label_font_size'],
109
+ text_color='black', font_family='courier',
110
+ shape=None, fill_shape=False)
111
+
112
+ # --- Display boundary conditions ---
113
+ if fixities:
114
+ bc_size = model_size * 0.04
115
+ for fixity in fixities:
116
+ coords = _nodecoords(fixity[0], nodes, ndm)
117
+ if coords is None:
118
+ continue
119
+
120
+ if ndm == 2:
121
+ if fixity[1] == 1:
122
+ tip = coords - np.array([bc_size * 0.5, 0, 0])
123
+ tri = pv.Triangle([
124
+ [tip[0] - bc_size, tip[1] - bc_size * 0.5, 0],
125
+ [tip[0], tip[1], 0],
126
+ [tip[0] - bc_size, tip[1] + bc_size * 0.5, 0],
127
+ ])
128
+ plotter.add_mesh(tri, color='black', style='wireframe',
129
+ line_width=2)
130
+ if fixity[2] == 1:
131
+ tip = coords - np.array([0, bc_size * 0.5, 0])
132
+ tri = pv.Triangle([
133
+ [tip[0] - bc_size * 0.5, tip[1] - bc_size, 0],
134
+ [tip[0], tip[1], 0],
135
+ [tip[0] + bc_size * 0.5, tip[1] - bc_size, 0],
136
+ ])
137
+ plotter.add_mesh(tri, color='black', style='wireframe',
138
+ line_width=2)
139
+ if len(fixity) > 3 and fixity[3] == 1:
140
+ circle = pv.Circle(radius=bc_size * 0.6, resolution=32)
141
+ circle.translate(coords, inplace=True)
142
+ plotter.add_mesh(circle, color='black', style='wireframe',
143
+ line_width=2)
144
+ else:
145
+ directions = [
146
+ np.array([1, 0, 0]),
147
+ np.array([0, 1, 0]),
148
+ np.array([0, 0, 1]),
149
+ ]
150
+ for i, d in enumerate(directions):
151
+ if fixity[i + 1] == 1:
152
+ start = coords - d * bc_size * 1.5
153
+ arrow = pv.Arrow(start=start, direction=d,
154
+ scale=bc_size * 1.5, tip_length=0.4,
155
+ tip_radius=0.15, shaft_radius=0.05)
156
+ plotter.add_mesh(arrow, color='black')
157
+
158
+
159
+ def view(*modelfiles, **kwargs):
160
+ """Open a live viewer for the given OpenSeesPy model file(s).
161
+
162
+ Parameters
163
+ ----------
164
+ *modelfiles : str
165
+ One or more paths to OpenSeesPy model files (.py).
166
+ **kwargs :
167
+ Optional style overrides (bg_colour, node_colour, node_size,
168
+ ele_colour, ele_width, axis_colour, label_font_size, offset_ratio,
169
+ min_refresh_interval).
170
+ """
171
+ if not modelfiles:
172
+ raise ValueError("At least one model file path is required.")
173
+
174
+ style = {**DEFAULTS, **kwargs}
175
+
176
+ # Determine 2D or 3D
177
+ _, _, _, ndm = parse_py(modelfiles)
178
+
179
+ # Start file watcher
180
+ observer, file_handler = start_watcher(modelfiles)
181
+
182
+ # Create plotter
183
+ plotter = pv.Plotter(title='OpenSeesPy Viewer - ' + ', '.join(modelfiles))
184
+ plotter.set_background(style['bg_colour'])
185
+
186
+ # Initial scene build
187
+ _build_scene(plotter, modelfiles, ndm, style)
188
+
189
+ if ndm == 2:
190
+ plotter.view_xy()
191
+ plotter.enable_parallel_projection()
192
+
193
+ def _update(step):
194
+ if not file_handler.changed.is_set():
195
+ return
196
+ file_handler.changed.clear()
197
+ plotter.clear()
198
+ _build_scene(plotter, modelfiles, ndm, style)
199
+ if ndm == 2:
200
+ plotter.view_xy()
201
+ plotter.render()
202
+
203
+ def _screenshot():
204
+ global _screenshot_counter
205
+ os.makedirs(SCREENSHOT_DIR, exist_ok=True)
206
+ _screenshot_counter += 1
207
+ path = os.path.join(SCREENSHOT_DIR, f'model_screenshot_{_screenshot_counter:03d}.png')
208
+ plotter.screenshot(path)
209
+ print(f'Screenshot saved to {path}')
210
+
211
+ plotter.add_key_event('s', _screenshot)
212
+
213
+ plotter.show(interactive_update=True)
214
+ plotter.add_timer_event(
215
+ max_steps=sys.maxsize,
216
+ duration=int(style['min_refresh_interval'] * 1000),
217
+ callback=_update,
218
+ )
219
+
220
+ try:
221
+ plotter.iren.start()
222
+ finally:
223
+ observer.stop()
224
+ observer.join()
@@ -0,0 +1,41 @@
1
+ """File watcher that detects changes in model files."""
2
+
3
+ import os
4
+ import threading
5
+ from watchdog.observers import Observer
6
+ from watchdog.events import FileSystemEventHandler
7
+
8
+
9
+ class ModelFileHandler(FileSystemEventHandler):
10
+ """Watches model files for changes and sets a flag when they are modified."""
11
+ def __init__(self, filenames):
12
+ self.filenames = {os.path.abspath(f) for f in filenames}
13
+ self.changed = threading.Event()
14
+ self.changed.set() # trigger initial draw
15
+
16
+ def _check(self, event):
17
+ if os.path.abspath(event.src_path) in self.filenames:
18
+ self.changed.set()
19
+
20
+ def on_modified(self, event):
21
+ self._check(event)
22
+
23
+ def on_created(self, event):
24
+ self._check(event)
25
+
26
+ def on_moved(self, event):
27
+ if os.path.abspath(event.dest_path) in self.filenames:
28
+ self.changed.set()
29
+
30
+
31
+ def start_watcher(modelfiles):
32
+ """Start a file watcher on the given model files.
33
+ Returns (observer, file_handler).
34
+ """
35
+ file_handler = ModelFileHandler(modelfiles)
36
+ observer = Observer()
37
+ watched_dirs = {os.path.abspath(os.path.dirname(f) or '.') for f in modelfiles}
38
+ for d in watched_dirs:
39
+ observer.schedule(file_handler, d, recursive=False)
40
+ observer.start()
41
+ return observer, file_handler