pyrender-maintained 1.0.0__py3-none-any.whl

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.
pyrender/viewer.py ADDED
@@ -0,0 +1,1157 @@
1
+ """A pyglet-based interactive 3D scene viewer.
2
+ """
3
+ import copy
4
+ import os
5
+ import sys
6
+ from threading import Thread, RLock
7
+ import time
8
+
9
+ import imageio
10
+ import numpy as np
11
+ import OpenGL
12
+ import trimesh
13
+
14
+ try:
15
+ from tkinter import Tk, filedialog
16
+ except Exception:
17
+ pass
18
+
19
+ from .constants import (TARGET_OPEN_GL_MAJOR, TARGET_OPEN_GL_MINOR,
20
+ MIN_OPEN_GL_MAJOR, MIN_OPEN_GL_MINOR,
21
+ TEXT_PADDING, DEFAULT_SCENE_SCALE,
22
+ DEFAULT_Z_FAR, DEFAULT_Z_NEAR, RenderFlags, TextAlign)
23
+ from .light import DirectionalLight
24
+ from .node import Node
25
+ from .camera import PerspectiveCamera, OrthographicCamera, IntrinsicsCamera
26
+ from .trackball import Trackball
27
+ from .renderer import Renderer
28
+ from .mesh import Mesh
29
+
30
+ import pyglet
31
+ from pyglet import clock
32
+ pyglet.options['shadow_window'] = False
33
+
34
+
35
+ class Viewer(pyglet.window.Window):
36
+ """An interactive viewer for 3D scenes.
37
+
38
+ The viewer's camera is separate from the scene's, but will take on
39
+ the parameters of the scene's main view camera and start in the same pose.
40
+ If the scene does not have a camera, a suitable default will be provided.
41
+
42
+ Parameters
43
+ ----------
44
+ scene : :class:`Scene`
45
+ The scene to visualize.
46
+ viewport_size : (2,) int
47
+ The width and height of the initial viewing window.
48
+ render_flags : dict
49
+ A set of flags for rendering the scene. Described in the note below.
50
+ viewer_flags : dict
51
+ A set of flags for controlling the viewer's behavior.
52
+ Described in the note below.
53
+ registered_keys : dict
54
+ A map from ASCII key characters to tuples containing:
55
+
56
+ - A function to be called whenever the key is pressed,
57
+ whose first argument will be the viewer itself.
58
+ - (Optionally) A list of additional positional arguments
59
+ to be passed to the function.
60
+ - (Optionally) A dict of keyword arguments to be passed
61
+ to the function.
62
+
63
+ kwargs : dict
64
+ Any keyword arguments left over will be interpreted as belonging to
65
+ either the :attr:`.Viewer.render_flags` or :attr:`.Viewer.viewer_flags`
66
+ dictionaries. Those flag sets will be updated appropriately.
67
+
68
+ Note
69
+ ----
70
+ The basic commands for moving about the scene are given as follows:
71
+
72
+ - **Rotating about the scene**: Hold the left mouse button and
73
+ drag the cursor.
74
+ - **Rotating about the view axis**: Hold ``CTRL`` and the left mouse
75
+ button and drag the cursor.
76
+ - **Panning**:
77
+
78
+ - Hold SHIFT, then hold the left mouse button and drag the cursor, or
79
+ - Hold the middle mouse button and drag the cursor.
80
+
81
+ - **Zooming**:
82
+
83
+ - Scroll the mouse wheel, or
84
+ - Hold the right mouse button and drag the cursor.
85
+
86
+ Other keyboard commands are as follows:
87
+
88
+ - ``a``: Toggles rotational animation mode.
89
+ - ``c``: Toggles backface culling.
90
+ - ``f``: Toggles fullscreen mode.
91
+ - ``h``: Toggles shadow rendering.
92
+ - ``i``: Toggles axis display mode
93
+ (no axes, world axis, mesh axes, all axes).
94
+ - ``l``: Toggles lighting mode
95
+ (scene lighting, Raymond lighting, or direct lighting).
96
+ - ``m``: Toggles face normal visualization.
97
+ - ``n``: Toggles vertex normal visualization.
98
+ - ``o``: Toggles orthographic mode.
99
+ - ``q``: Quits the viewer.
100
+ - ``r``: Starts recording a GIF, and pressing again stops recording
101
+ and opens a file dialog.
102
+ - ``s``: Opens a file dialog to save the current view as an image.
103
+ - ``w``: Toggles wireframe mode
104
+ (scene default, flip wireframes, all wireframe, or all solid).
105
+ - ``z``: Resets the camera to the initial view.
106
+
107
+ Note
108
+ ----
109
+ The valid keys for ``render_flags`` are as follows:
110
+
111
+ - ``flip_wireframe``: `bool`, If `True`, all objects will have their
112
+ wireframe modes flipped from what their material indicates.
113
+ Defaults to `False`.
114
+ - ``all_wireframe``: `bool`, If `True`, all objects will be rendered
115
+ in wireframe mode. Defaults to `False`.
116
+ - ``all_solid``: `bool`, If `True`, all objects will be rendered in
117
+ solid mode. Defaults to `False`.
118
+ - ``shadows``: `bool`, If `True`, shadows will be rendered.
119
+ Defaults to `False`.
120
+ - ``vertex_normals``: `bool`, If `True`, vertex normals will be
121
+ rendered as blue lines. Defaults to `False`.
122
+ - ``face_normals``: `bool`, If `True`, face normals will be rendered as
123
+ blue lines. Defaults to `False`.
124
+ - ``cull_faces``: `bool`, If `True`, backfaces will be culled.
125
+ Defaults to `True`.
126
+ - ``point_size`` : float, The point size in pixels. Defaults to 1px.
127
+
128
+ Note
129
+ ----
130
+ The valid keys for ``viewer_flags`` are as follows:
131
+
132
+ - ``rotate``: `bool`, If `True`, the scene's camera will rotate
133
+ about an axis. Defaults to `False`.
134
+ - ``rotate_rate``: `float`, The rate of rotation in radians per second.
135
+ Defaults to `PI / 3.0`.
136
+ - ``rotate_axis``: `(3,) float`, The axis in world coordinates to rotate
137
+ about. Defaults to ``[0,0,1]``.
138
+ - ``view_center``: `(3,) float`, The position to rotate the scene about.
139
+ Defaults to the scene's centroid.
140
+ - ``use_raymond_lighting``: `bool`, If `True`, an additional set of three
141
+ directional lights that move with the camera will be added to the scene.
142
+ Defaults to `False`.
143
+ - ``use_direct_lighting``: `bool`, If `True`, an additional directional
144
+ light that moves with the camera and points out of it will be added to
145
+ the scene. Defaults to `False`.
146
+ - ``lighting_intensity``: `float`, The overall intensity of the
147
+ viewer's additional lights (when they're in use). Defaults to 3.0.
148
+ - ``use_perspective_cam``: `bool`, If `True`, a perspective camera will
149
+ be used. Otherwise, an orthographic camera is used. Defaults to `True`.
150
+ - ``save_directory``: `str`, A directory to open the file dialogs in.
151
+ Defaults to `None`.
152
+ - ``window_title``: `str`, A title for the viewer's application window.
153
+ Defaults to `"Scene Viewer"`.
154
+ - ``refresh_rate``: `float`, A refresh rate for rendering, in Hertz.
155
+ Defaults to `30.0`.
156
+ - ``fullscreen``: `bool`, Whether to make viewer fullscreen.
157
+ Defaults to `False`.
158
+ - ``show_world_axis``: `bool`, Whether to show the world axis.
159
+ Defaults to `False`.
160
+ - ``show_mesh_axes``: `bool`, Whether to show the individual mesh axes.
161
+ Defaults to `False`.
162
+ - ``caption``: `list of dict`, Text caption(s) to display on the viewer.
163
+ Defaults to `None`.
164
+
165
+ Note
166
+ ----
167
+ Animation can be accomplished by running the viewer with ``run_in_thread``
168
+ enabled. Then, just run a loop in your main thread, updating the scene as
169
+ needed. Before updating the scene, be sure to acquire the
170
+ :attr:`.Viewer.render_lock`, and release it when your update is done.
171
+ """
172
+
173
+ def __init__(self, scene, viewport_size=None,
174
+ render_flags=None, viewer_flags=None,
175
+ registered_keys=None, run_in_thread=False,
176
+ auto_start=True,
177
+ **kwargs):
178
+
179
+ #######################################################################
180
+ # Save attributes and flags
181
+ #######################################################################
182
+ if viewport_size is None:
183
+ viewport_size = (640, 480)
184
+ self._scene = scene
185
+ self._viewport_size = viewport_size
186
+ self._render_lock = RLock()
187
+ self._is_active = False
188
+ self._should_close = False
189
+ self._run_in_thread = run_in_thread
190
+ self._auto_start = auto_start
191
+
192
+ self._default_render_flags = {
193
+ 'flip_wireframe': False,
194
+ 'all_wireframe': False,
195
+ 'all_solid': False,
196
+ 'shadows': False,
197
+ 'vertex_normals': False,
198
+ 'face_normals': False,
199
+ 'cull_faces': True,
200
+ 'point_size': 1.0,
201
+ }
202
+ self._default_viewer_flags = {
203
+ 'mouse_pressed': False,
204
+ 'rotate': False,
205
+ 'rotate_rate': np.pi / 3.0,
206
+ 'rotate_axis': np.array([0.0, 0.0, 1.0]),
207
+ 'view_center': None,
208
+ 'record': False,
209
+ 'use_raymond_lighting': False,
210
+ 'use_direct_lighting': False,
211
+ 'lighting_intensity': 3.0,
212
+ 'use_perspective_cam': True,
213
+ 'save_directory': None,
214
+ 'window_title': 'Scene Viewer',
215
+ 'refresh_rate': 30.0,
216
+ 'fullscreen': False,
217
+ 'show_world_axis': False,
218
+ 'show_mesh_axes': False,
219
+ 'caption': None
220
+ }
221
+ self._render_flags = self._default_render_flags.copy()
222
+ self._viewer_flags = self._default_viewer_flags.copy()
223
+ self._viewer_flags['rotate_axis'] = (
224
+ self._default_viewer_flags['rotate_axis'].copy()
225
+ )
226
+
227
+ if render_flags is not None:
228
+ self._render_flags.update(render_flags)
229
+ if viewer_flags is not None:
230
+ self._viewer_flags.update(viewer_flags)
231
+
232
+ for key in kwargs:
233
+ if key in self.render_flags:
234
+ self._render_flags[key] = kwargs[key]
235
+ elif key in self.viewer_flags:
236
+ self._viewer_flags[key] = kwargs[key]
237
+
238
+ # TODO MAC OS BUG FOR SHADOWS
239
+ if sys.platform == 'darwin':
240
+ self._render_flags['shadows'] = False
241
+
242
+ self._registered_keys = {}
243
+ if registered_keys is not None:
244
+ self._registered_keys = {
245
+ ord(k.lower()): registered_keys[k] for k in registered_keys
246
+ }
247
+
248
+ #######################################################################
249
+ # Save internal settings
250
+ #######################################################################
251
+
252
+ # Set up caption stuff
253
+ self._message_text = None
254
+ self._ticks_till_fade = 2.0 / 3.0 * self.viewer_flags['refresh_rate']
255
+ self._message_opac = 1.0 + self._ticks_till_fade
256
+
257
+ # Set up raymond lights and direct lights
258
+ self._raymond_lights = self._create_raymond_lights()
259
+ self._direct_light = self._create_direct_light()
260
+
261
+ # Set up axes
262
+ self._axes = {}
263
+ self._axis_mesh = Mesh.from_trimesh(
264
+ trimesh.creation.axis(origin_size=0.1, axis_radius=0.05,
265
+ axis_length=1.0), smooth=False)
266
+ if self.viewer_flags['show_world_axis']:
267
+ self._set_axes(world=self.viewer_flags['show_world_axis'],
268
+ mesh=self.viewer_flags['show_mesh_axes'])
269
+
270
+ #######################################################################
271
+ # Set up camera node
272
+ #######################################################################
273
+ self._camera_node = None
274
+ self._prior_main_camera_node = None
275
+ self._default_camera_pose = None
276
+ self._default_persp_cam = None
277
+ self._default_orth_cam = None
278
+ self._trackball = None
279
+ self._saved_frames = []
280
+
281
+ # Extract main camera from scene and set up our mirrored copy
282
+ znear = None
283
+ zfar = None
284
+ if scene.main_camera_node is not None:
285
+ n = scene.main_camera_node
286
+ camera = copy.copy(n.camera)
287
+ if isinstance(camera, (PerspectiveCamera, IntrinsicsCamera)):
288
+ self._default_persp_cam = camera
289
+ znear = camera.znear
290
+ zfar = camera.zfar
291
+ elif isinstance(camera, OrthographicCamera):
292
+ self._default_orth_cam = camera
293
+ znear = camera.znear
294
+ zfar = camera.zfar
295
+ self._default_camera_pose = scene.get_pose(scene.main_camera_node)
296
+ self._prior_main_camera_node = n
297
+
298
+ # Set defaults as needed
299
+ if zfar is None:
300
+ zfar = max(scene.scale * 10.0, DEFAULT_Z_FAR)
301
+ if znear is None or znear == 0:
302
+ if scene.scale == 0:
303
+ znear = DEFAULT_Z_NEAR
304
+ else:
305
+ znear = min(scene.scale / 10.0, DEFAULT_Z_NEAR)
306
+
307
+ if self._default_persp_cam is None:
308
+ self._default_persp_cam = PerspectiveCamera(
309
+ yfov=np.pi / 3.0, znear=znear, zfar=zfar
310
+ )
311
+ if self._default_orth_cam is None:
312
+ xmag = ymag = scene.scale
313
+ if scene.scale == 0:
314
+ xmag = ymag = 1.0
315
+ self._default_orth_cam = OrthographicCamera(
316
+ xmag=xmag, ymag=ymag,
317
+ znear=znear,
318
+ zfar=zfar
319
+ )
320
+ if self._default_camera_pose is None:
321
+ self._default_camera_pose = self._compute_initial_camera_pose()
322
+
323
+ # Pick camera
324
+ if self.viewer_flags['use_perspective_cam']:
325
+ camera = self._default_persp_cam
326
+ else:
327
+ camera = self._default_orth_cam
328
+
329
+ self._camera_node = Node(
330
+ matrix=self._default_camera_pose, camera=camera
331
+ )
332
+ scene.add_node(self._camera_node)
333
+ scene.main_camera_node = self._camera_node
334
+ self._reset_view()
335
+
336
+ #######################################################################
337
+ # Initialize OpenGL context and renderer
338
+ #######################################################################
339
+ self._renderer = Renderer(
340
+ self._viewport_size[0], self._viewport_size[1],
341
+ self.render_flags['point_size']
342
+ )
343
+ self._is_active = True
344
+
345
+ if self.run_in_thread:
346
+ self._thread = Thread(target=self._init_and_start_app)
347
+ self._thread.start()
348
+ else:
349
+ if auto_start:
350
+ self._init_and_start_app()
351
+
352
+ def start(self):
353
+ self._init_and_start_app()
354
+
355
+ @property
356
+ def scene(self):
357
+ """:class:`.Scene` : The scene being visualized.
358
+ """
359
+ return self._scene
360
+
361
+ @property
362
+ def viewport_size(self):
363
+ """(2,) int : The width and height of the viewing window.
364
+ """
365
+ return self._viewport_size
366
+
367
+ @property
368
+ def render_lock(self):
369
+ """:class:`threading.RLock` : If acquired, prevents the viewer from
370
+ rendering until released.
371
+
372
+ Run :meth:`.Viewer.render_lock.acquire` before making updates to
373
+ the scene in a different thread, and run
374
+ :meth:`.Viewer.render_lock.release` once you're done to let the viewer
375
+ continue.
376
+ """
377
+ return self._render_lock
378
+
379
+ @property
380
+ def is_active(self):
381
+ """bool : `True` if the viewer is active, or `False` if it has
382
+ been closed.
383
+ """
384
+ return self._is_active
385
+
386
+ @property
387
+ def run_in_thread(self):
388
+ """bool : Whether the viewer was run in a separate thread.
389
+ """
390
+ return self._run_in_thread
391
+
392
+ @property
393
+ def render_flags(self):
394
+ """dict : Flags for controlling the renderer's behavior.
395
+
396
+ - ``flip_wireframe``: `bool`, If `True`, all objects will have their
397
+ wireframe modes flipped from what their material indicates.
398
+ Defaults to `False`.
399
+ - ``all_wireframe``: `bool`, If `True`, all objects will be rendered
400
+ in wireframe mode. Defaults to `False`.
401
+ - ``all_solid``: `bool`, If `True`, all objects will be rendered in
402
+ solid mode. Defaults to `False`.
403
+ - ``shadows``: `bool`, If `True`, shadows will be rendered.
404
+ Defaults to `False`.
405
+ - ``vertex_normals``: `bool`, If `True`, vertex normals will be
406
+ rendered as blue lines. Defaults to `False`.
407
+ - ``face_normals``: `bool`, If `True`, face normals will be rendered as
408
+ blue lines. Defaults to `False`.
409
+ - ``cull_faces``: `bool`, If `True`, backfaces will be culled.
410
+ Defaults to `True`.
411
+ - ``point_size`` : float, The point size in pixels. Defaults to 1px.
412
+
413
+ """
414
+ return self._render_flags
415
+
416
+ @render_flags.setter
417
+ def render_flags(self, value):
418
+ self._render_flags = value
419
+
420
+ @property
421
+ def viewer_flags(self):
422
+ """dict : Flags for controlling the viewer's behavior.
423
+
424
+ The valid keys for ``viewer_flags`` are as follows:
425
+
426
+ - ``rotate``: `bool`, If `True`, the scene's camera will rotate
427
+ about an axis. Defaults to `False`.
428
+ - ``rotate_rate``: `float`, The rate of rotation in radians per second.
429
+ Defaults to `PI / 3.0`.
430
+ - ``rotate_axis``: `(3,) float`, The axis in world coordinates to
431
+ rotate about. Defaults to ``[0,0,1]``.
432
+ - ``view_center``: `(3,) float`, The position to rotate the scene
433
+ about. Defaults to the scene's centroid.
434
+ - ``use_raymond_lighting``: `bool`, If `True`, an additional set of
435
+ three directional lights that move with the camera will be added to
436
+ the scene. Defaults to `False`.
437
+ - ``use_direct_lighting``: `bool`, If `True`, an additional directional
438
+ light that moves with the camera and points out of it will be
439
+ added to the scene. Defaults to `False`.
440
+ - ``lighting_intensity``: `float`, The overall intensity of the
441
+ viewer's additional lights (when they're in use). Defaults to 3.0.
442
+ - ``use_perspective_cam``: `bool`, If `True`, a perspective camera will
443
+ be used. Otherwise, an orthographic camera is used. Defaults to
444
+ `True`.
445
+ - ``save_directory``: `str`, A directory to open the file dialogs in.
446
+ Defaults to `None`.
447
+ - ``window_title``: `str`, A title for the viewer's application window.
448
+ Defaults to `"Scene Viewer"`.
449
+ - ``refresh_rate``: `float`, A refresh rate for rendering, in Hertz.
450
+ Defaults to `30.0`.
451
+ - ``fullscreen``: `bool`, Whether to make viewer fullscreen.
452
+ Defaults to `False`.
453
+ - ``show_world_axis``: `bool`, Whether to show the world axis.
454
+ Defaults to `False`.
455
+ - ``show_mesh_axes``: `bool`, Whether to show the individual mesh axes.
456
+ Defaults to `False`.
457
+ - ``caption``: `list of dict`, Text caption(s) to display on
458
+ the viewer. Defaults to `None`.
459
+
460
+ """
461
+ return self._viewer_flags
462
+
463
+ @viewer_flags.setter
464
+ def viewer_flags(self, value):
465
+ self._viewer_flags = value
466
+
467
+ @property
468
+ def registered_keys(self):
469
+ """dict : Map from ASCII key character to a handler function.
470
+
471
+ This is a map from ASCII key characters to tuples containing:
472
+
473
+ - A function to be called whenever the key is pressed,
474
+ whose first argument will be the viewer itself.
475
+ - (Optionally) A list of additional positional arguments
476
+ to be passed to the function.
477
+ - (Optionally) A dict of keyword arguments to be passed
478
+ to the function.
479
+
480
+ """
481
+ return self._registered_keys
482
+
483
+ @registered_keys.setter
484
+ def registered_keys(self, value):
485
+ self._registered_keys = value
486
+
487
+ def close_external(self):
488
+ """Close the viewer from another thread.
489
+
490
+ This function will wait for the actual close, so you immediately
491
+ manipulate the scene afterwards.
492
+ """
493
+ self._should_close = True
494
+ while self.is_active:
495
+ time.sleep(1.0 / self.viewer_flags['refresh_rate'])
496
+
497
+ def save_gif(self, filename=None):
498
+ """Save the stored GIF frames to a file.
499
+
500
+ To use this asynchronously, run the viewer with the ``record``
501
+ flag and the ``run_in_thread`` flags set.
502
+ Kill the viewer after your desired time with
503
+ :meth:`.Viewer.close_external`, and then call :meth:`.Viewer.save_gif`.
504
+
505
+ Parameters
506
+ ----------
507
+ filename : str
508
+ The file to save the GIF to. If not specified,
509
+ a file dialog will be opened to ask the user where
510
+ to save the GIF file.
511
+ """
512
+ if filename is None:
513
+ filename = self._get_save_filename(['gif', 'all'])
514
+ if filename is not None:
515
+ self.viewer_flags['save_directory'] = os.path.dirname(filename)
516
+ imageio.mimwrite(filename, self._saved_frames,
517
+ fps=self.viewer_flags['refresh_rate'],
518
+ palettesize=128, subrectangles=True)
519
+ self._saved_frames = []
520
+
521
+ def on_close(self):
522
+ """Exit the event loop when the window is closed.
523
+ """
524
+ # Remove our camera and restore the prior one
525
+ if self._camera_node is not None:
526
+ self.scene.remove_node(self._camera_node)
527
+ if self._prior_main_camera_node is not None:
528
+ self.scene.main_camera_node = self._prior_main_camera_node
529
+
530
+ # Delete any lighting nodes that we've attached
531
+ if self.viewer_flags['use_raymond_lighting']:
532
+ for n in self._raymond_lights:
533
+ if self.scene.has_node(n):
534
+ self.scene.remove_node(n)
535
+ if self.viewer_flags['use_direct_lighting']:
536
+ if self.scene.has_node(self._direct_light):
537
+ self.scene.remove_node(self._direct_light)
538
+
539
+ # Delete any axis nodes that we've attached
540
+ self._remove_axes()
541
+
542
+ # Delete renderer
543
+ if self._renderer is not None:
544
+ self._renderer.delete()
545
+ self._renderer = None
546
+
547
+ # Force clean-up of OpenGL context data
548
+ try:
549
+ OpenGL.contextdata.cleanupContext()
550
+ self.close()
551
+ except Exception:
552
+ pass
553
+ finally:
554
+ self._is_active = False
555
+ super(Viewer, self).on_close()
556
+ pyglet.app.exit()
557
+
558
+ def on_draw(self):
559
+ """Redraw the scene into the viewing window.
560
+ """
561
+ if self._renderer is None:
562
+ return
563
+
564
+ if self.run_in_thread or not self._auto_start:
565
+ self.render_lock.acquire()
566
+
567
+ # Make OpenGL context current
568
+ self.switch_to()
569
+
570
+ # Render the scene
571
+ self.clear()
572
+ self._render()
573
+
574
+ if self._message_text is not None:
575
+ self._renderer.render_text(
576
+ self._message_text,
577
+ self.viewport_size[0] - TEXT_PADDING,
578
+ TEXT_PADDING,
579
+ font_pt=20,
580
+ color=np.array([0.1, 0.7, 0.2,
581
+ np.clip(self._message_opac, 0.0, 1.0)]),
582
+ align=TextAlign.BOTTOM_RIGHT
583
+ )
584
+
585
+ if self.viewer_flags['caption'] is not None:
586
+ for caption in self.viewer_flags['caption']:
587
+ xpos, ypos = self._location_to_x_y(caption['location'])
588
+ self._renderer.render_text(
589
+ caption['text'],
590
+ xpos,
591
+ ypos,
592
+ font_name=caption['font_name'],
593
+ font_pt=caption['font_pt'],
594
+ color=caption['color'],
595
+ scale=caption['scale'],
596
+ align=caption['location']
597
+ )
598
+
599
+ if self.run_in_thread or not self._auto_start:
600
+ self.render_lock.release()
601
+
602
+ def on_resize(self, width, height):
603
+ """Resize the camera and trackball when the window is resized.
604
+ """
605
+ if self._renderer is None:
606
+ return
607
+
608
+ self._viewport_size = (width, height)
609
+ self._trackball.resize(self._viewport_size)
610
+ self._renderer.viewport_width = self._viewport_size[0]
611
+ self._renderer.viewport_height = self._viewport_size[1]
612
+ self.on_draw()
613
+
614
+ def on_mouse_press(self, x, y, buttons, modifiers):
615
+ """Record an initial mouse press.
616
+ """
617
+ self._trackball.set_state(Trackball.STATE_ROTATE)
618
+ if (buttons == pyglet.window.mouse.LEFT):
619
+ ctrl = (modifiers & pyglet.window.key.MOD_CTRL)
620
+ shift = (modifiers & pyglet.window.key.MOD_SHIFT)
621
+ if (ctrl and shift):
622
+ self._trackball.set_state(Trackball.STATE_ZOOM)
623
+ elif ctrl:
624
+ self._trackball.set_state(Trackball.STATE_ROLL)
625
+ elif shift:
626
+ self._trackball.set_state(Trackball.STATE_PAN)
627
+ elif (buttons == pyglet.window.mouse.MIDDLE):
628
+ self._trackball.set_state(Trackball.STATE_PAN)
629
+ elif (buttons == pyglet.window.mouse.RIGHT):
630
+ self._trackball.set_state(Trackball.STATE_ZOOM)
631
+
632
+ self._trackball.down(np.array([x, y]))
633
+
634
+ # Stop animating while using the mouse
635
+ self.viewer_flags['mouse_pressed'] = True
636
+
637
+ def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers):
638
+ """Record a mouse drag.
639
+ """
640
+ self._trackball.drag(np.array([x, y]))
641
+
642
+ def on_mouse_release(self, x, y, button, modifiers):
643
+ """Record a mouse release.
644
+ """
645
+ self.viewer_flags['mouse_pressed'] = False
646
+
647
+ def on_mouse_scroll(self, x, y, dx, dy):
648
+ """Record a mouse scroll.
649
+ """
650
+ if self.viewer_flags['use_perspective_cam']:
651
+ self._trackball.scroll(dy)
652
+ else:
653
+ spfc = 0.95
654
+ spbc = 1.0 / 0.95
655
+ sf = 1.0
656
+ if dy > 0:
657
+ sf = spfc * dy
658
+ elif dy < 0:
659
+ sf = - spbc * dy
660
+
661
+ c = self._camera_node.camera
662
+ xmag = max(c.xmag * sf, 1e-8)
663
+ ymag = max(c.ymag * sf, 1e-8 * c.ymag / c.xmag)
664
+ c.xmag = xmag
665
+ c.ymag = ymag
666
+
667
+ def on_key_press(self, symbol, modifiers):
668
+ """Record a key press.
669
+ """
670
+ # First, check for registered key callbacks
671
+ if symbol in self.registered_keys:
672
+ tup = self.registered_keys[symbol]
673
+ callback = None
674
+ args = []
675
+ kwargs = {}
676
+ if not isinstance(tup, (list, tuple, np.ndarray)):
677
+ callback = tup
678
+ else:
679
+ callback = tup[0]
680
+ if len(tup) == 2:
681
+ args = tup[1]
682
+ if len(tup) == 3:
683
+ kwargs = tup[2]
684
+ callback(self, *args, **kwargs)
685
+ return
686
+
687
+ # Otherwise, use default key functions
688
+
689
+ # A causes the frame to rotate
690
+ self._message_text = None
691
+ if symbol == pyglet.window.key.A:
692
+ self.viewer_flags['rotate'] = not self.viewer_flags['rotate']
693
+ if self.viewer_flags['rotate']:
694
+ self._message_text = 'Rotation On'
695
+ else:
696
+ self._message_text = 'Rotation Off'
697
+
698
+ # C toggles backface culling
699
+ elif symbol == pyglet.window.key.C:
700
+ self.render_flags['cull_faces'] = (
701
+ not self.render_flags['cull_faces']
702
+ )
703
+ if self.render_flags['cull_faces']:
704
+ self._message_text = 'Cull Faces On'
705
+ else:
706
+ self._message_text = 'Cull Faces Off'
707
+
708
+ # F toggles face normals
709
+ elif symbol == pyglet.window.key.F:
710
+ self.viewer_flags['fullscreen'] = (
711
+ not self.viewer_flags['fullscreen']
712
+ )
713
+ self.set_fullscreen(self.viewer_flags['fullscreen'])
714
+ self.activate()
715
+ if self.viewer_flags['fullscreen']:
716
+ self._message_text = 'Fullscreen On'
717
+ else:
718
+ self._message_text = 'Fullscreen Off'
719
+
720
+ # S toggles shadows
721
+ elif symbol == pyglet.window.key.H and sys.platform != 'darwin':
722
+ self.render_flags['shadows'] = not self.render_flags['shadows']
723
+ if self.render_flags['shadows']:
724
+ self._message_text = 'Shadows On'
725
+ else:
726
+ self._message_text = 'Shadows Off'
727
+
728
+ elif symbol == pyglet.window.key.I:
729
+ if (self.viewer_flags['show_world_axis'] and not
730
+ self.viewer_flags['show_mesh_axes']):
731
+ self.viewer_flags['show_world_axis'] = False
732
+ self.viewer_flags['show_mesh_axes'] = True
733
+ self._set_axes(False, True)
734
+ self._message_text = 'Mesh Axes On'
735
+ elif (not self.viewer_flags['show_world_axis'] and
736
+ self.viewer_flags['show_mesh_axes']):
737
+ self.viewer_flags['show_world_axis'] = True
738
+ self.viewer_flags['show_mesh_axes'] = True
739
+ self._set_axes(True, True)
740
+ self._message_text = 'All Axes On'
741
+ elif (self.viewer_flags['show_world_axis'] and
742
+ self.viewer_flags['show_mesh_axes']):
743
+ self.viewer_flags['show_world_axis'] = False
744
+ self.viewer_flags['show_mesh_axes'] = False
745
+ self._set_axes(False, False)
746
+ self._message_text = 'All Axes Off'
747
+ else:
748
+ self.viewer_flags['show_world_axis'] = True
749
+ self.viewer_flags['show_mesh_axes'] = False
750
+ self._set_axes(True, False)
751
+ self._message_text = 'World Axis On'
752
+
753
+ # L toggles the lighting mode
754
+ elif symbol == pyglet.window.key.L:
755
+ if self.viewer_flags['use_raymond_lighting']:
756
+ self.viewer_flags['use_raymond_lighting'] = False
757
+ self.viewer_flags['use_direct_lighting'] = True
758
+ self._message_text = 'Direct Lighting'
759
+ elif self.viewer_flags['use_direct_lighting']:
760
+ self.viewer_flags['use_raymond_lighting'] = False
761
+ self.viewer_flags['use_direct_lighting'] = False
762
+ self._message_text = 'Default Lighting'
763
+ else:
764
+ self.viewer_flags['use_raymond_lighting'] = True
765
+ self.viewer_flags['use_direct_lighting'] = False
766
+ self._message_text = 'Raymond Lighting'
767
+
768
+ # M toggles face normals
769
+ elif symbol == pyglet.window.key.M:
770
+ self.render_flags['face_normals'] = (
771
+ not self.render_flags['face_normals']
772
+ )
773
+ if self.render_flags['face_normals']:
774
+ self._message_text = 'Face Normals On'
775
+ else:
776
+ self._message_text = 'Face Normals Off'
777
+
778
+ # N toggles vertex normals
779
+ elif symbol == pyglet.window.key.N:
780
+ self.render_flags['vertex_normals'] = (
781
+ not self.render_flags['vertex_normals']
782
+ )
783
+ if self.render_flags['vertex_normals']:
784
+ self._message_text = 'Vert Normals On'
785
+ else:
786
+ self._message_text = 'Vert Normals Off'
787
+
788
+ # O toggles orthographic camera mode
789
+ elif symbol == pyglet.window.key.O:
790
+ self.viewer_flags['use_perspective_cam'] = (
791
+ not self.viewer_flags['use_perspective_cam']
792
+ )
793
+ if self.viewer_flags['use_perspective_cam']:
794
+ camera = self._default_persp_cam
795
+ self._message_text = 'Perspective View'
796
+ else:
797
+ camera = self._default_orth_cam
798
+ self._message_text = 'Orthographic View'
799
+
800
+ cam_pose = self._camera_node.matrix.copy()
801
+ cam_node = Node(matrix=cam_pose, camera=camera)
802
+ self.scene.remove_node(self._camera_node)
803
+ self.scene.add_node(cam_node)
804
+ self.scene.main_camera_node = cam_node
805
+ self._camera_node = cam_node
806
+
807
+ # Q quits the viewer
808
+ elif symbol == pyglet.window.key.Q:
809
+ self.on_close()
810
+
811
+ # R starts recording frames
812
+ elif symbol == pyglet.window.key.R:
813
+ if self.viewer_flags['record']:
814
+ self.save_gif()
815
+ self.set_caption(self.viewer_flags['window_title'])
816
+ else:
817
+ self.set_caption(
818
+ '{} (RECORDING)'.format(self.viewer_flags['window_title'])
819
+ )
820
+ self.viewer_flags['record'] = not self.viewer_flags['record']
821
+
822
+ # S saves the current frame as an image
823
+ elif symbol == pyglet.window.key.S:
824
+ self._save_image()
825
+
826
+ # W toggles through wireframe modes
827
+ elif symbol == pyglet.window.key.W:
828
+ if self.render_flags['flip_wireframe']:
829
+ self.render_flags['flip_wireframe'] = False
830
+ self.render_flags['all_wireframe'] = True
831
+ self.render_flags['all_solid'] = False
832
+ self._message_text = 'All Wireframe'
833
+ elif self.render_flags['all_wireframe']:
834
+ self.render_flags['flip_wireframe'] = False
835
+ self.render_flags['all_wireframe'] = False
836
+ self.render_flags['all_solid'] = True
837
+ self._message_text = 'All Solid'
838
+ elif self.render_flags['all_solid']:
839
+ self.render_flags['flip_wireframe'] = False
840
+ self.render_flags['all_wireframe'] = False
841
+ self.render_flags['all_solid'] = False
842
+ self._message_text = 'Default Wireframe'
843
+ else:
844
+ self.render_flags['flip_wireframe'] = True
845
+ self.render_flags['all_wireframe'] = False
846
+ self.render_flags['all_solid'] = False
847
+ self._message_text = 'Flip Wireframe'
848
+
849
+ # Z resets the camera viewpoint
850
+ elif symbol == pyglet.window.key.Z:
851
+ self._reset_view()
852
+
853
+ if self._message_text is not None:
854
+ self._message_opac = 1.0 + self._ticks_till_fade
855
+
856
+ @staticmethod
857
+ def _time_event(dt, self):
858
+ """The timer callback.
859
+ """
860
+ # Don't run old dead events after we've already closed
861
+ if not self._is_active:
862
+ return
863
+
864
+ if self.viewer_flags['record']:
865
+ self._record()
866
+ if (self.viewer_flags['rotate'] and not
867
+ self.viewer_flags['mouse_pressed']):
868
+ self._rotate()
869
+
870
+ # Manage message opacity
871
+ if self._message_text is not None:
872
+ if self._message_opac > 1.0:
873
+ self._message_opac -= 1.0
874
+ else:
875
+ self._message_opac *= 0.90
876
+ if self._message_opac < 0.05:
877
+ self._message_opac = 1.0 + self._ticks_till_fade
878
+ self._message_text = None
879
+
880
+ if self._should_close:
881
+ self.on_close()
882
+ else:
883
+ self.on_draw()
884
+
885
+ def _reset_view(self):
886
+ """Reset the view to a good initial state.
887
+
888
+ The view is initially along the positive x-axis at a
889
+ sufficient distance from the scene.
890
+ """
891
+ scale = self.scene.scale
892
+ if scale == 0.0:
893
+ scale = DEFAULT_SCENE_SCALE
894
+ centroid = self.scene.centroid
895
+
896
+ if self.viewer_flags['view_center'] is not None:
897
+ centroid = self.viewer_flags['view_center']
898
+
899
+ self._camera_node.matrix = self._default_camera_pose
900
+ self._trackball = Trackball(
901
+ self._default_camera_pose, self.viewport_size, scale, centroid
902
+ )
903
+
904
+ def _get_save_filename(self, file_exts):
905
+ file_types = {
906
+ 'png': ('png files', '*.png'),
907
+ 'jpg': ('jpeg files', '*.jpg'),
908
+ 'gif': ('gif files', '*.gif'),
909
+ 'all': ('all files', '*'),
910
+ }
911
+ filetypes = [file_types[x] for x in file_exts]
912
+ try:
913
+ root = Tk()
914
+ save_dir = self.viewer_flags['save_directory']
915
+ if save_dir is None:
916
+ save_dir = os.getcwd()
917
+ filename = filedialog.asksaveasfilename(
918
+ initialdir=save_dir, title='Select file save location',
919
+ filetypes=filetypes
920
+ )
921
+ except Exception:
922
+ return None
923
+
924
+ root.destroy()
925
+ if filename == ():
926
+ return None
927
+ return filename
928
+
929
+ def _save_image(self):
930
+ filename = self._get_save_filename(['png', 'jpg', 'gif', 'all'])
931
+ if filename is not None:
932
+ self.viewer_flags['save_directory'] = os.path.dirname(filename)
933
+ imageio.imwrite(filename, self._renderer.read_color_buf())
934
+
935
+ def _record(self):
936
+ """Save another frame for the GIF.
937
+ """
938
+ data = self._renderer.read_color_buf()
939
+ if not np.all(data == 0.0):
940
+ self._saved_frames.append(data)
941
+
942
+ def _rotate(self):
943
+ """Animate the scene by rotating the camera.
944
+ """
945
+ az = (self.viewer_flags['rotate_rate'] /
946
+ self.viewer_flags['refresh_rate'])
947
+ self._trackball.rotate(az, self.viewer_flags['rotate_axis'])
948
+
949
+ def _render(self):
950
+ """Render the scene into the framebuffer and flip.
951
+ """
952
+ scene = self.scene
953
+ self._camera_node.matrix = self._trackball.pose.copy()
954
+
955
+ # Set lighting
956
+ vli = self.viewer_flags['lighting_intensity']
957
+ if self.viewer_flags['use_raymond_lighting']:
958
+ for n in self._raymond_lights:
959
+ n.light.intensity = vli / 3.0
960
+ if not self.scene.has_node(n):
961
+ scene.add_node(n, parent_node=self._camera_node)
962
+ else:
963
+ self._direct_light.light.intensity = vli
964
+ for n in self._raymond_lights:
965
+ if self.scene.has_node(n):
966
+ self.scene.remove_node(n)
967
+
968
+ if self.viewer_flags['use_direct_lighting']:
969
+ if not self.scene.has_node(self._direct_light):
970
+ scene.add_node(
971
+ self._direct_light, parent_node=self._camera_node
972
+ )
973
+ elif self.scene.has_node(self._direct_light):
974
+ self.scene.remove_node(self._direct_light)
975
+
976
+ flags = RenderFlags.NONE
977
+ if self.render_flags['flip_wireframe']:
978
+ flags |= RenderFlags.FLIP_WIREFRAME
979
+ elif self.render_flags['all_wireframe']:
980
+ flags |= RenderFlags.ALL_WIREFRAME
981
+ elif self.render_flags['all_solid']:
982
+ flags |= RenderFlags.ALL_SOLID
983
+
984
+ if self.render_flags['shadows']:
985
+ flags |= RenderFlags.SHADOWS_DIRECTIONAL | RenderFlags.SHADOWS_SPOT
986
+ if self.render_flags['vertex_normals']:
987
+ flags |= RenderFlags.VERTEX_NORMALS
988
+ if self.render_flags['face_normals']:
989
+ flags |= RenderFlags.FACE_NORMALS
990
+ if not self.render_flags['cull_faces']:
991
+ flags |= RenderFlags.SKIP_CULL_FACES
992
+
993
+ self._renderer.render(self.scene, flags)
994
+
995
+ def _init_and_start_app(self):
996
+ # Try multiple configs starting with target OpenGL version
997
+ # and multisampling and removing these options if exception
998
+ # Note: multisampling not available on all hardware
999
+ from pyglet.gl import Config
1000
+ confs = [Config(sample_buffers=1, samples=4,
1001
+ depth_size=24,
1002
+ double_buffer=True,
1003
+ major_version=TARGET_OPEN_GL_MAJOR,
1004
+ minor_version=TARGET_OPEN_GL_MINOR),
1005
+ Config(depth_size=24,
1006
+ double_buffer=True,
1007
+ major_version=TARGET_OPEN_GL_MAJOR,
1008
+ minor_version=TARGET_OPEN_GL_MINOR),
1009
+ Config(sample_buffers=1, samples=4,
1010
+ depth_size=24,
1011
+ double_buffer=True,
1012
+ major_version=MIN_OPEN_GL_MAJOR,
1013
+ minor_version=MIN_OPEN_GL_MINOR),
1014
+ Config(depth_size=24,
1015
+ double_buffer=True,
1016
+ major_version=MIN_OPEN_GL_MAJOR,
1017
+ minor_version=MIN_OPEN_GL_MINOR)]
1018
+ for conf in confs:
1019
+ try:
1020
+ super(Viewer, self).__init__(config=conf, resizable=True,
1021
+ width=self._viewport_size[0],
1022
+ height=self._viewport_size[1])
1023
+ break
1024
+ except pyglet.window.NoSuchConfigException:
1025
+ pass
1026
+
1027
+ if not self.context:
1028
+ raise ValueError('Unable to initialize an OpenGL 3+ context')
1029
+ clock.schedule_interval(
1030
+ Viewer._time_event, 1.0 / self.viewer_flags['refresh_rate'], self
1031
+ )
1032
+ self.switch_to()
1033
+ self.set_caption(self.viewer_flags['window_title'])
1034
+ pyglet.app.run()
1035
+
1036
+ def _compute_initial_camera_pose(self):
1037
+ centroid = self.scene.centroid
1038
+ if self.viewer_flags['view_center'] is not None:
1039
+ centroid = self.viewer_flags['view_center']
1040
+ scale = self.scene.scale
1041
+ if scale == 0.0:
1042
+ scale = DEFAULT_SCENE_SCALE
1043
+
1044
+ s2 = 1.0 / np.sqrt(2.0)
1045
+ cp = np.eye(4)
1046
+ cp[:3,:3] = np.array([
1047
+ [0.0, -s2, s2],
1048
+ [1.0, 0.0, 0.0],
1049
+ [0.0, s2, s2]
1050
+ ])
1051
+ hfov = np.pi / 6.0
1052
+ dist = scale / (2.0 * np.tan(hfov))
1053
+ cp[:3,3] = dist * np.array([1.0, 0.0, 1.0]) + centroid
1054
+
1055
+ return cp
1056
+
1057
+ def _create_raymond_lights(self):
1058
+ thetas = np.pi * np.array([1.0 / 6.0, 1.0 / 6.0, 1.0 / 6.0])
1059
+ phis = np.pi * np.array([0.0, 2.0 / 3.0, 4.0 / 3.0])
1060
+
1061
+ nodes = []
1062
+
1063
+ for phi, theta in zip(phis, thetas):
1064
+ xp = np.sin(theta) * np.cos(phi)
1065
+ yp = np.sin(theta) * np.sin(phi)
1066
+ zp = np.cos(theta)
1067
+
1068
+ z = np.array([xp, yp, zp])
1069
+ z = z / np.linalg.norm(z)
1070
+ x = np.array([-z[1], z[0], 0.0])
1071
+ if np.linalg.norm(x) == 0:
1072
+ x = np.array([1.0, 0.0, 0.0])
1073
+ x = x / np.linalg.norm(x)
1074
+ y = np.cross(z, x)
1075
+
1076
+ matrix = np.eye(4)
1077
+ matrix[:3,:3] = np.c_[x,y,z]
1078
+ nodes.append(Node(
1079
+ light=DirectionalLight(color=np.ones(3), intensity=1.0),
1080
+ matrix=matrix
1081
+ ))
1082
+
1083
+ return nodes
1084
+
1085
+ def _create_direct_light(self):
1086
+ light = DirectionalLight(color=np.ones(3), intensity=1.0)
1087
+ n = Node(light=light, matrix=np.eye(4))
1088
+ return n
1089
+
1090
+ def _set_axes(self, world, mesh):
1091
+ scale = self.scene.scale
1092
+ if world:
1093
+ if 'scene' not in self._axes:
1094
+ n = Node(mesh=self._axis_mesh, scale=np.ones(3) * scale * 0.3)
1095
+ self.scene.add_node(n)
1096
+ self._axes['scene'] = n
1097
+ else:
1098
+ if 'scene' in self._axes:
1099
+ self.scene.remove_node(self._axes['scene'])
1100
+ self._axes.pop('scene')
1101
+
1102
+ if mesh:
1103
+ old_nodes = []
1104
+ existing_axes = set([self._axes[k] for k in self._axes])
1105
+ for node in self.scene.mesh_nodes:
1106
+ if node not in existing_axes:
1107
+ old_nodes.append(node)
1108
+
1109
+ for node in old_nodes:
1110
+ if node in self._axes:
1111
+ continue
1112
+ n = Node(
1113
+ mesh=self._axis_mesh,
1114
+ scale=np.ones(3) * node.mesh.scale * 0.5
1115
+ )
1116
+ self.scene.add_node(n, parent_node=node)
1117
+ self._axes[node] = n
1118
+ else:
1119
+ to_remove = set()
1120
+ for main_node in self._axes:
1121
+ if main_node in self.scene.mesh_nodes:
1122
+ self.scene.remove_node(self._axes[main_node])
1123
+ to_remove.add(main_node)
1124
+ for main_node in to_remove:
1125
+ self._axes.pop(main_node)
1126
+
1127
+ def _remove_axes(self):
1128
+ for main_node in self._axes:
1129
+ axis_node = self._axes[main_node]
1130
+ self.scene.remove_node(axis_node)
1131
+ self._axes = {}
1132
+
1133
+ def _location_to_x_y(self, location):
1134
+ if location == TextAlign.CENTER:
1135
+ return (self.viewport_size[0] / 2.0, self.viewport_size[1] / 2.0)
1136
+ elif location == TextAlign.CENTER_LEFT:
1137
+ return (TEXT_PADDING, self.viewport_size[1] / 2.0)
1138
+ elif location == TextAlign.CENTER_RIGHT:
1139
+ return (self.viewport_size[0] - TEXT_PADDING,
1140
+ self.viewport_size[1] / 2.0)
1141
+ elif location == TextAlign.BOTTOM_LEFT:
1142
+ return (TEXT_PADDING, TEXT_PADDING)
1143
+ elif location == TextAlign.BOTTOM_RIGHT:
1144
+ return (self.viewport_size[0] - TEXT_PADDING, TEXT_PADDING)
1145
+ elif location == TextAlign.BOTTOM_CENTER:
1146
+ return (self.viewport_size[0] / 2.0, TEXT_PADDING)
1147
+ elif location == TextAlign.TOP_LEFT:
1148
+ return (TEXT_PADDING, self.viewport_size[1] - TEXT_PADDING)
1149
+ elif location == TextAlign.TOP_RIGHT:
1150
+ return (self.viewport_size[0] - TEXT_PADDING,
1151
+ self.viewport_size[1] - TEXT_PADDING)
1152
+ elif location == TextAlign.TOP_CENTER:
1153
+ return (self.viewport_size[0] / 2.0,
1154
+ self.viewport_size[1] - TEXT_PADDING)
1155
+
1156
+
1157
+ __all__ = ['Viewer']