|
| 1 | +from asyncio import ( |
| 2 | + new_event_loop, |
| 3 | + set_event_loop, |
| 4 | + run_coroutine_threadsafe, |
| 5 | + AbstractEventLoop, |
| 6 | +) |
| 7 | +from concurrent.futures import Future |
| 8 | +from typing import Optional |
| 9 | +from threading import Thread |
| 10 | +from itertools import chain |
| 11 | +from webbrowser import open_new_tab |
| 12 | + |
| 13 | +from typish import instance_of |
| 14 | + |
| 15 | +from trame.app import get_server |
| 16 | +from trame.app.core import Server |
| 17 | +from trame.widgets import html, vtk as vtk_widgets, client |
| 18 | +from trame.ui.html import DivLayout |
| 19 | + |
| 20 | +from . import Shape |
| 21 | +from .vis import style, Showable, ShapeLike, _split_showables |
| 22 | + |
| 23 | +from vtkmodules.vtkRenderingCore import ( |
| 24 | + vtkRenderer, |
| 25 | + vtkRenderWindow, |
| 26 | + vtkRenderWindowInteractor, |
| 27 | + vtkProp3D, |
| 28 | +) |
| 29 | + |
| 30 | + |
| 31 | +from vtkmodules.vtkInteractionWidgets import vtkOrientationMarkerWidget |
| 32 | +from vtkmodules.vtkRenderingAnnotation import vtkAxesActor |
| 33 | + |
| 34 | +from vtkmodules.vtkInteractionStyle import vtkInteractorStyleTrackballCamera |
| 35 | + |
| 36 | +FULL_SCREEN = "position:absolute; left:0; top:0; width:100vw; height:100vh;" |
| 37 | + |
| 38 | + |
| 39 | +class Figure: |
| 40 | + |
| 41 | + server: Server |
| 42 | + win: vtkRenderWindow |
| 43 | + ren: vtkRenderer |
| 44 | + view: vtk_widgets.VtkRemoteView |
| 45 | + shapes: dict[ShapeLike, list[vtkProp3D]] |
| 46 | + actors: list[vtkProp3D] |
| 47 | + loop: AbstractEventLoop |
| 48 | + thread: Thread |
| 49 | + empty: bool |
| 50 | + last: Optional[ |
| 51 | + tuple[ |
| 52 | + list[ShapeLike], list[vtkProp3D], Optional[list[vtkProp3D]], list[vtkProp3D] |
| 53 | + ] |
| 54 | + ] |
| 55 | + |
| 56 | + _instance = None |
| 57 | + _initialized: bool = False |
| 58 | + |
| 59 | + def __new__(cls, *args, **kwargs): |
| 60 | + |
| 61 | + if not cls._instance: |
| 62 | + cls._instance = object.__new__(cls) |
| 63 | + |
| 64 | + return cls._instance |
| 65 | + |
| 66 | + def __init__(self, port: int = 18081): |
| 67 | + |
| 68 | + if self._initialized: |
| 69 | + return |
| 70 | + |
| 71 | + self.loop = new_event_loop() |
| 72 | + set_event_loop(self.loop) |
| 73 | + |
| 74 | + # vtk boilerplate |
| 75 | + renderer = vtkRenderer() |
| 76 | + win = vtkRenderWindow() |
| 77 | + w, h = win.GetScreenSize() |
| 78 | + win.SetSize(w, h) |
| 79 | + win.AddRenderer(renderer) |
| 80 | + win.OffScreenRenderingOn() |
| 81 | + |
| 82 | + inter = vtkRenderWindowInteractor() |
| 83 | + inter.SetInteractorStyle(vtkInteractorStyleTrackballCamera()) |
| 84 | + inter.SetRenderWindow(win) |
| 85 | + |
| 86 | + # background |
| 87 | + renderer.SetBackground(1, 1, 1) |
| 88 | + renderer.GradientBackgroundOn() |
| 89 | + |
| 90 | + # axes |
| 91 | + axes = vtkAxesActor() |
| 92 | + axes.SetDragable(0) |
| 93 | + |
| 94 | + orient_widget = vtkOrientationMarkerWidget() |
| 95 | + |
| 96 | + orient_widget.SetOrientationMarker(axes) |
| 97 | + orient_widget.SetViewport(0.9, 0.0, 1.0, 0.2) |
| 98 | + orient_widget.SetZoom(1.1) |
| 99 | + orient_widget.SetInteractor(inter) |
| 100 | + orient_widget.SetCurrentRenderer(renderer) |
| 101 | + orient_widget.EnabledOn() |
| 102 | + orient_widget.InteractiveOff() |
| 103 | + |
| 104 | + self.axes = axes |
| 105 | + self.orient_widget = orient_widget |
| 106 | + self.win = win |
| 107 | + self.ren = renderer |
| 108 | + |
| 109 | + self.shapes = {} |
| 110 | + self.actors = [] |
| 111 | + |
| 112 | + # server |
| 113 | + server = get_server("CQ-server") |
| 114 | + server.client_type = "vue3" |
| 115 | + |
| 116 | + # layout |
| 117 | + with DivLayout(server): |
| 118 | + client.Style("body { margin: 0; }") |
| 119 | + |
| 120 | + with html.Div(style=FULL_SCREEN): |
| 121 | + self.view = vtk_widgets.VtkRemoteView( |
| 122 | + win, interactive_ratio=1, interactive_quality=100 |
| 123 | + ) |
| 124 | + |
| 125 | + server.state.flush() |
| 126 | + |
| 127 | + self.server = server |
| 128 | + self.loop = new_event_loop() |
| 129 | + |
| 130 | + def _run_loop(): |
| 131 | + set_event_loop(self.loop) |
| 132 | + self.loop.run_forever() |
| 133 | + |
| 134 | + self.thread = Thread(target=_run_loop, daemon=True) |
| 135 | + self.thread.start() |
| 136 | + |
| 137 | + coro = server.start( |
| 138 | + thread=True, |
| 139 | + exec_mode="coroutine", |
| 140 | + port=port, |
| 141 | + open_browser=False, |
| 142 | + show_connection_info=False, |
| 143 | + ) |
| 144 | + |
| 145 | + if coro: |
| 146 | + self._run(coro) |
| 147 | + |
| 148 | + # prevent reinitialization |
| 149 | + self._initialized = True |
| 150 | + |
| 151 | + # view is initialized as empty |
| 152 | + self.empty = True |
| 153 | + self.last = None |
| 154 | + |
| 155 | + # open webbrowser |
| 156 | + open_new_tab(f"http://localhost:{port}") |
| 157 | + |
| 158 | + def _run(self, coro) -> Future: |
| 159 | + |
| 160 | + return run_coroutine_threadsafe(coro, self.loop) |
| 161 | + |
| 162 | + def show(self, *showables: Showable | vtkProp3D | list[vtkProp3D], **kwargs): |
| 163 | + """ |
| 164 | + Show objects. |
| 165 | + """ |
| 166 | + |
| 167 | + # split objects |
| 168 | + shapes, vecs, locs, props = _split_showables(showables) |
| 169 | + |
| 170 | + pts = style(vecs, **kwargs) |
| 171 | + axs = style(locs, **kwargs) |
| 172 | + |
| 173 | + for s in shapes: |
| 174 | + # do not show markers by default |
| 175 | + if "markersize" not in kwargs: |
| 176 | + kwargs["markersize"] = 0 |
| 177 | + |
| 178 | + actors = style(s, **kwargs) |
| 179 | + self.shapes[s] = actors |
| 180 | + |
| 181 | + for actor in actors: |
| 182 | + self.ren.AddActor(actor) |
| 183 | + |
| 184 | + for prop in chain(props, axs): |
| 185 | + self.actors.append(prop) |
| 186 | + self.ren.AddActor(prop) |
| 187 | + |
| 188 | + if vecs: |
| 189 | + self.actors.append(*pts) |
| 190 | + self.ren.AddActor(*pts) |
| 191 | + |
| 192 | + # store to enable pop |
| 193 | + self.last = (shapes, axs, pts if vecs else None, props) |
| 194 | + |
| 195 | + async def _show(): |
| 196 | + self.view.update() |
| 197 | + |
| 198 | + self._run(_show()) |
| 199 | + |
| 200 | + # zoom to fit on 1st object added |
| 201 | + if self.empty: |
| 202 | + self.fit() |
| 203 | + self.empty = False |
| 204 | + |
| 205 | + return self |
| 206 | + |
| 207 | + def fit(self): |
| 208 | + """ |
| 209 | + Update view to fit all objects. |
| 210 | + """ |
| 211 | + |
| 212 | + async def _show(): |
| 213 | + self.ren.ResetCamera() |
| 214 | + self.view.update() |
| 215 | + |
| 216 | + self._run(_show()) |
| 217 | + |
| 218 | + return self |
| 219 | + |
| 220 | + def clear(self, *shapes: Shape | vtkProp3D): |
| 221 | + """ |
| 222 | + Clear specified objects. If no arguments are passed, clears all objects. |
| 223 | + """ |
| 224 | + |
| 225 | + async def _clear(): |
| 226 | + |
| 227 | + if len(shapes) == 0: |
| 228 | + self.ren.RemoveAllViewProps() |
| 229 | + |
| 230 | + self.actors.clear() |
| 231 | + self.shapes.clear() |
| 232 | + |
| 233 | + for s in shapes: |
| 234 | + if instance_of(s, ShapeLike): |
| 235 | + for a in self.shapes[s]: |
| 236 | + self.ren.RemoveActor(a) |
| 237 | + |
| 238 | + del self.shapes[s] |
| 239 | + else: |
| 240 | + self.actors.remove(s) |
| 241 | + self.ren.RemoveActor(s) |
| 242 | + |
| 243 | + self.view.update() |
| 244 | + |
| 245 | + # reset last, bc we don't want to keep track of what was removed |
| 246 | + self.last = None |
| 247 | + future = self._run(_clear()) |
| 248 | + future.result() |
| 249 | + |
| 250 | + return self |
| 251 | + |
| 252 | + def pop(self): |
| 253 | + """ |
| 254 | + Clear the last showable. |
| 255 | + """ |
| 256 | + |
| 257 | + async def _pop(): |
| 258 | + |
| 259 | + (shapes, axs, pts, props) = self.last |
| 260 | + |
| 261 | + for s in shapes: |
| 262 | + for act in self.shapes.pop(s): |
| 263 | + self.ren.RemoveActor(act) |
| 264 | + |
| 265 | + for act in chain(axs, props): |
| 266 | + self.ren.RemoveActor(act) |
| 267 | + self.actors.remove(act) |
| 268 | + |
| 269 | + if pts: |
| 270 | + self.ren.RemoveActor(*pts) |
| 271 | + self.actors.remove(*pts) |
| 272 | + |
| 273 | + self.view.update() |
| 274 | + |
| 275 | + self._run(_pop()) |
| 276 | + |
| 277 | + return self |
0 commit comments