Coverage for trimesh/viewer/windowed.py: 73%

390 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 04:40 +0000

1""" 

2windowed.py 

3--------------- 

4 

5Provides a pyglet- based windowed viewer to preview 

6Trimesh, Scene, PointCloud, and Path objects. 

7 

8Works on all major platforms: Windows, Linux, and OSX. 

9""" 

10 

11import collections 

12 

13import numpy as np 

14import pyglet 

15 

16# pyglet 2.0 is close to a re-write moving from fixed-function 

17# to shaders and we will likely support it by forking an entirely 

18# new viewer `trimesh.viewer.shaders` and then basically keeping 

19# `windowed` around for backwards-compatibility with no changes 

20if int(pyglet.version.split(".")[0]) >= 2: 

21 raise ImportError('`trimesh.viewer.windowed` requires `pip install "pyglet<2"`') 

22 

23from .. import rendering, util 

24from ..transformations import translation_matrix 

25from ..typed import ArrayLike, Callable, Iterable, Number 

26from ..visual import to_rgba 

27from .trackball import Trackball 

28 

29pyglet.options["shadow_window"] = False 

30 

31import pyglet.gl as gl # NOQA 

32 

33# help message for the viewer 

34_HELP_MESSAGE = """ 

35# SceneViewer Controls 

36 

37| Input | Action | 

38|----------------------------|-----------------------------------------------| 

39| `mouse click + drag` | Rotates the view | 

40| `ctl + mouse click + drag` | Pans the view | 

41| `mouse wheel` | Zooms the view | 

42| `z` | Resets to the initial view | 

43| `w` | Toggles wireframe mode | 

44| `c` | Toggles backface culling | 

45| `g` | Toggles an XY grid with Z set to lowest point | 

46| `a` | Toggles an XYZ-RGB axis marker between: off, | 

47| | at world frame, or at every frame and world, | 

48| | and at every frame | 

49| `f` | Toggles between fullscreen and windowed mode | 

50| `h` | Prints this help message | 

51| `m` | Maximizes the window | 

52| `q` | Closes the window | 

53|----------------------------|-----------------------------------------------| 

54""" 

55 

56 

57class SceneViewer(pyglet.window.Window): 

58 def __init__( 

59 self, 

60 scene, 

61 smooth: bool = True, 

62 flags: dict | None = None, 

63 visible: bool = True, 

64 resolution: ArrayLike | None = None, 

65 fullscreen: bool = False, 

66 resizable: bool = True, 

67 start_loop: bool = True, 

68 callback: Callable | None = None, 

69 callback_period: Number | None = None, 

70 caption: str | None = None, 

71 fixed: Iterable | None = None, 

72 offset_lines: bool = True, 

73 line_settings: dict | None = None, 

74 background=None, 

75 window_conf=None, 

76 profile: bool = False, 

77 record: bool = False, 

78 **kwargs, 

79 ): 

80 """ 

81 Create a window that will display a trimesh.Scene object 

82 in an OpenGL context via pyglet. 

83 

84 Parameters 

85 --------------- 

86 scene : trimesh.scene.Scene 

87 Scene with geometry and transforms 

88 smooth 

89 If True try to smooth shade things 

90 flags 

91 If passed apply keys to self.view: 

92 ['cull', 'wireframe', etc] 

93 visible 

94 Display window or not 

95 resolution 

96 Initial resolution of window 

97 fullscreen 

98 Determines whether the window is rendered in fullscreen mode. 

99 resizable 

100 Determines whether the rendered window can be resized by the user. 

101 start_loop 

102 Call pyglet.app.run() at the end of init 

103 callback 

104 A function which can be called periodically to 

105 update things in the scene 

106 callback_period 

107 How often to call the callback, in seconds 

108 caption 

109 Caption for the window title 

110 fixed 

111 List of keys in scene.geometry to skip view 

112 transform on to keep fixed relative to camera 

113 offset_lines 

114 If True, will offset lines slightly so if drawn 

115 coplanar with mesh geometry they will be visible 

116 line_settings 

117 Override default line width and point size with keys 

118 'line_width' and 'point_size' in pixels 

119 background 

120 Color for background 

121 window_conf 

122 Passed to window init 

123 profile 

124 If set will run a `pyinstrument` profile for 

125 every call to `on_draw` and print the output. 

126 record 

127 If True, will save a list of `png` bytes to 

128 a list located in `scene.metadata['recording']` 

129 kwargs 

130 Additional arguments to pass, including 

131 'background' for to set background color 

132 """ 

133 self.scene = self._scene = scene 

134 

135 self.callback = callback 

136 self.callback_period = callback_period 

137 self.scene._redraw = self._redraw 

138 self.offset_lines = bool(offset_lines) 

139 self.background = background 

140 # save initial camera transform 

141 self._initial_camera_transform = scene.camera_transform.copy() 

142 

143 # a transform to offset lines slightly to avoid Z-fighting 

144 self._line_offset = translation_matrix( 

145 [0, 0, scene.scale / 1000 if self.offset_lines else 0] 

146 ) 

147 

148 self.reset_view() 

149 self.batch = pyglet.graphics.Batch() 

150 self._smooth = smooth 

151 

152 self._profile = bool(profile) 

153 if self._profile: 

154 from pyinstrument import Profiler 

155 

156 self.Profiler = Profiler 

157 

158 self._record = bool(record) 

159 if self._record: 

160 # will save bytes here 

161 self.scene.metadata["recording"] = [] 

162 

163 # store kwargs 

164 self.kwargs = kwargs 

165 

166 # store a vertexlist for an axis marker 

167 self._axis = None 

168 # store a vertexlist for a grid display 

169 self._grid = None 

170 # store scene geometry as vertex lists 

171 self.vertex_list = {} 

172 # store geometry hashes 

173 self.vertex_list_hash = {} 

174 # store geometry rendering mode 

175 self.vertex_list_mode = {} 

176 # store meshes that don't rotate relative to viewer 

177 self.fixed = fixed 

178 # store a hidden (don't not display) node. 

179 self._nodes_hidden = set() 

180 # name : texture 

181 self.textures = {} 

182 

183 # if resolution isn't defined set a default value 

184 if resolution is None: 

185 resolution = scene.camera.resolution 

186 else: 

187 scene.camera.resolution = resolution 

188 

189 if caption is None: 

190 caption = "Trimesh SceneViewer (`h` for help)" 

191 

192 # set the default line settings to a fraction 

193 # of our resolution so the points aren't tiny 

194 scale = max(resolution) 

195 self.line_settings = {"point_size": scale / 200, "line_width": scale / 400} 

196 # if we've been passed line settings override the default 

197 if line_settings is not None: 

198 self.line_settings.update(line_settings) 

199 

200 # no window conf was passed so try to get the best looking one 

201 if window_conf is None: 

202 try: 

203 # try enabling antialiasing 

204 # if you have a graphics card this will probably work 

205 conf = gl.Config( 

206 sample_buffers=1, samples=4, depth_size=24, double_buffer=True 

207 ) 

208 super().__init__( 

209 config=conf, 

210 visible=visible, 

211 fullscreen=fullscreen, 

212 resizable=resizable, 

213 width=resolution[0], 

214 height=resolution[1], 

215 caption=caption, 

216 ) 

217 except pyglet.window.NoSuchConfigException: 

218 conf = gl.Config(double_buffer=True) 

219 super().__init__( 

220 config=conf, 

221 fullscreen=fullscreen, 

222 resizable=resizable, 

223 visible=visible, 

224 width=resolution[0], 

225 height=resolution[1], 

226 caption=caption, 

227 ) 

228 else: 

229 # window config was manually passed 

230 super().__init__( 

231 config=window_conf, 

232 fullscreen=fullscreen, 

233 resizable=resizable, 

234 visible=visible, 

235 width=resolution[0], 

236 height=resolution[1], 

237 caption=caption, 

238 ) 

239 

240 # add scene geometry to viewer geometry 

241 self._update_vertex_list() 

242 

243 # call after geometry is added 

244 self.init_gl() 

245 self.set_size(*resolution) 

246 if flags is not None: 

247 self.reset_view(flags=flags) 

248 self.update_flags() 

249 

250 # someone has passed a callback to be called periodically 

251 if self.callback is not None: 

252 # if no callback period is specified set it to default 

253 if callback_period is None: 

254 # 30 times per second 

255 callback_period = 1.0 / 30.0 

256 # set up a do-nothing periodic task which will 

257 # trigger `self.on_draw` every `callback_period` 

258 # seconds if someone has passed a callback 

259 pyglet.clock.schedule_interval(lambda x: x, callback_period) 

260 if start_loop: 

261 pyglet.app.run() 

262 

263 def _redraw(self): 

264 self.on_draw() 

265 

266 def _update_vertex_list(self): 

267 # update vertex_list if needed 

268 for name, geom in self.scene.geometry.items(): 

269 if geom.is_empty: 

270 continue 

271 if _geometry_hash(geom) == self.vertex_list_hash.get(name): 

272 continue 

273 self.add_geometry(name=name, geometry=geom, smooth=bool(self._smooth)) 

274 

275 def _update_meshes(self): 

276 # call the callback if specified 

277 if self.callback is not None: 

278 self.callback(self.scene) 

279 self._update_vertex_list() 

280 self._update_perspective(self.width, self.height) 

281 

282 def add_geometry(self, name, geometry, **kwargs): 

283 """ 

284 Add a geometry to the viewer. 

285 

286 Parameters 

287 -------------- 

288 name : hashable 

289 Name that references geometry 

290 geometry : Trimesh, Path2D, Path3D, PointCloud 

291 Geometry to display in the viewer window 

292 kwargs ** 

293 Passed to rendering.convert_to_vertexlist 

294 """ 

295 try: 

296 # convert geometry to constructor args 

297 args = rendering.convert_to_vertexlist(geometry, **kwargs) 

298 except BaseException: 

299 util.log.warning(f"failed to add geometry `{name}`", exc_info=True) 

300 return 

301 

302 # create the indexed vertex list 

303 self.vertex_list[name] = self.batch.add_indexed(*args) 

304 # save the hash of the geometry 

305 self.vertex_list_hash[name] = _geometry_hash(geometry) 

306 # save the rendering mode from the constructor args 

307 self.vertex_list_mode[name] = args[1] 

308 

309 # get the visual if the element has it 

310 visual = getattr(geometry, "visual", None) 

311 if hasattr(visual, "uv") and hasattr(visual, "material"): 

312 try: 

313 tex = rendering.material_to_texture(visual.material) 

314 if tex is not None: 

315 self.textures[name] = tex 

316 except BaseException: 

317 util.log.warning("failed to load texture", exc_info=True) 

318 

319 def cleanup_geometries(self): 

320 """ 

321 Remove any stored vertex lists that no longer 

322 exist in the scene. 

323 """ 

324 # shorthand to scene graph 

325 graph = self.scene.graph 

326 # which parts of the graph still have geometry 

327 geom_keep = {graph[node][1] for node in graph.nodes_geometry} 

328 # which geometries no longer need to be kept 

329 geom_delete = [geom for geom in self.vertex_list if geom not in geom_keep] 

330 for geom in geom_delete: 

331 # remove stored vertex references 

332 self.vertex_list.pop(geom, None) 

333 self.vertex_list_hash.pop(geom, None) 

334 self.vertex_list_mode.pop(geom, None) 

335 self.textures.pop(geom, None) 

336 

337 def unhide_geometry(self, node): 

338 """ 

339 If a node is hidden remove the flag and show the 

340 geometry on the next draw. 

341 

342 Parameters 

343 ------------- 

344 node : str 

345 Node to display 

346 """ 

347 self._nodes_hidden.discard(node) 

348 

349 def hide_geometry(self, node): 

350 """ 

351 Don't display the geometry contained at a node on 

352 the next draw. 

353 

354 Parameters 

355 ------------- 

356 node : str 

357 Node to not display 

358 """ 

359 self._nodes_hidden.add(node) 

360 

361 def reset_view(self, flags=None): 

362 """ 

363 Set view to the default view. 

364 

365 Parameters 

366 -------------- 

367 flags : None or dict 

368 If any view key passed override the default 

369 e.g. {'cull': False} 

370 """ 

371 self.view = { 

372 "cull": True, 

373 "axis": False, 

374 "grid": False, 

375 "fullscreen": False, 

376 "wireframe": False, 

377 "ball": Trackball( 

378 pose=self._initial_camera_transform, 

379 size=self.scene.camera.resolution, 

380 scale=self.scene.scale, 

381 target=self.scene.centroid, 

382 ), 

383 } 

384 try: 

385 # if any flags are passed override defaults 

386 if isinstance(flags, dict): 

387 for k, v in flags.items(): 

388 if k in self.view: 

389 self.view[k] = v 

390 self.update_flags() 

391 except BaseException: 

392 pass 

393 

394 def init_gl(self): 

395 """ 

396 Perform the magic incantations to create an 

397 OpenGL scene using pyglet. 

398 """ 

399 

400 # if user passed a background color use it 

401 if self.background is None: 

402 # default background color is white 

403 background = np.ones(4) 

404 else: 

405 # convert to (4,) uint8 RGBA 

406 background = to_rgba(self.background) 

407 # convert to 0.0-1.0 float 

408 background = background.astype(np.float64) / 255.0 

409 

410 self._gl_set_background(background) 

411 # use camera setting for depth 

412 self._gl_enable_depth(self.scene.camera) 

413 self._gl_enable_color_material() 

414 self._gl_enable_blending() 

415 self._gl_enable_smooth_lines(**self.line_settings) 

416 self._gl_enable_lighting(self.scene) 

417 

418 @staticmethod 

419 def _gl_set_background(background): 

420 gl.glClearColor(*background) 

421 

422 @staticmethod 

423 def _gl_unset_background(): 

424 gl.glClearColor(*[0, 0, 0, 0]) 

425 

426 @staticmethod 

427 def _gl_enable_depth(camera): 

428 """ 

429 Enable depth test in OpenGL using distances 

430 from `scene.camera`. 

431 """ 

432 gl.glClearDepth(1.0) 

433 gl.glEnable(gl.GL_DEPTH_TEST) 

434 gl.glDepthFunc(gl.GL_LEQUAL) 

435 

436 gl.glEnable(gl.GL_DEPTH_TEST) 

437 gl.glEnable(gl.GL_CULL_FACE) 

438 

439 @staticmethod 

440 def _gl_enable_color_material(): 

441 # do some openGL things 

442 gl.glColorMaterial(gl.GL_FRONT_AND_BACK, gl.GL_AMBIENT_AND_DIFFUSE) 

443 gl.glEnable(gl.GL_COLOR_MATERIAL) 

444 gl.glShadeModel(gl.GL_SMOOTH) 

445 

446 gl.glMaterialfv( 

447 gl.GL_FRONT, 

448 gl.GL_AMBIENT, 

449 rendering.vector_to_gl(0.192250, 0.192250, 0.192250), 

450 ) 

451 gl.glMaterialfv( 

452 gl.GL_FRONT, 

453 gl.GL_DIFFUSE, 

454 rendering.vector_to_gl(0.507540, 0.507540, 0.507540), 

455 ) 

456 gl.glMaterialfv( 

457 gl.GL_FRONT, 

458 gl.GL_SPECULAR, 

459 rendering.vector_to_gl(0.5082730, 0.5082730, 0.5082730), 

460 ) 

461 

462 gl.glMaterialf(gl.GL_FRONT, gl.GL_SHININESS, 0.4 * 128.0) 

463 

464 @staticmethod 

465 def _gl_enable_blending(): 

466 # enable blending for transparency 

467 gl.glEnable(gl.GL_BLEND) 

468 gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) 

469 

470 @staticmethod 

471 def _gl_enable_smooth_lines(line_width=4, point_size=4): 

472 # make the lines from Path3D objects less ugly 

473 gl.glEnable(gl.GL_LINE_SMOOTH) 

474 gl.glHint(gl.GL_LINE_SMOOTH_HINT, gl.GL_NICEST) 

475 # set the width of lines to 4 pixels 

476 gl.glLineWidth(line_width) 

477 # set PointCloud markers to 4 pixels in size 

478 gl.glPointSize(point_size) 

479 

480 @staticmethod 

481 def _gl_enable_lighting(scene): 

482 """ 

483 Take the lights defined in scene.lights and 

484 apply them as openGL lights. 

485 """ 

486 gl.glEnable(gl.GL_LIGHTING) 

487 # opengl only supports 7 lights? 

488 for i, light in enumerate(scene.lights[:7]): 

489 # the index of which light we have 

490 lightN = getattr(gl, f"GL_LIGHT{i}") 

491 

492 # get the transform for the light by name 

493 matrix = scene.graph.get(light.name)[0] 

494 

495 # convert light object to glLightfv calls 

496 multiargs = rendering.light_to_gl( 

497 light=light, transform=matrix, lightN=lightN 

498 ) 

499 

500 # enable the light in question 

501 gl.glEnable(lightN) 

502 # run the glLightfv calls 

503 for args in multiargs: 

504 gl.glLightfv(*args) 

505 

506 def toggle_culling(self): 

507 """ 

508 Toggle back face culling. 

509 

510 It is on by default but if you are dealing with 

511 non- watertight meshes you probably want to be able 

512 to see the back sides. 

513 """ 

514 self.view["cull"] = not self.view["cull"] 

515 self.update_flags() 

516 

517 def toggle_wireframe(self): 

518 """ 

519 Toggle wireframe mode 

520 

521 Good for looking inside meshes, off by default. 

522 """ 

523 self.view["wireframe"] = not self.view["wireframe"] 

524 self.update_flags() 

525 

526 def toggle_fullscreen(self): 

527 """ 

528 Toggle between fullscreen and windowed mode. 

529 """ 

530 self.view["fullscreen"] = not self.view["fullscreen"] 

531 self.update_flags() 

532 

533 def toggle_axis(self): 

534 """ 

535 Toggle a rendered XYZ/RGB axis marker: 

536 off, world frame, every frame 

537 """ 

538 # cycle through three axis states 

539 states = [False, "world", "all", "without_world"] 

540 # the state after toggling 

541 index = (states.index(self.view["axis"]) + 1) % len(states) 

542 # update state to next index 

543 self.view["axis"] = states[index] 

544 # perform gl actions 

545 self.update_flags() 

546 

547 def toggle_grid(self): 

548 """ 

549 Toggle a rendered grid. 

550 """ 

551 # update state to next index 

552 self.view["grid"] = not self.view["grid"] 

553 # perform gl actions 

554 self.update_flags() 

555 

556 def update_flags(self): 

557 """ 

558 Check the view flags, and call required GL functions. 

559 """ 

560 # view mode, filled vs wirefrom 

561 if self.view["wireframe"]: 

562 gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) 

563 else: 

564 gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) 

565 

566 # set fullscreen or windowed 

567 self.set_fullscreen(fullscreen=self.view["fullscreen"]) 

568 

569 # backface culling on or off 

570 if self.view["cull"]: 

571 gl.glEnable(gl.GL_CULL_FACE) 

572 else: 

573 gl.glDisable(gl.GL_CULL_FACE) 

574 

575 # case where we WANT an axis and NO vertexlist 

576 # is stored internally 

577 if self.view["axis"] and self._axis is None: 

578 from .. import creation 

579 

580 # create an axis marker sized relative to the scene 

581 axis = creation.axis(origin_size=self.scene.scale / 100) 

582 # create ordered args for a vertex list 

583 args = rendering.mesh_to_vertexlist(axis) 

584 # store the axis as a reference 

585 self._axis = self.batch.add_indexed(*args) 

586 # case where we DON'T want an axis but a vertexlist 

587 # IS stored internally 

588 elif not self.view["axis"] and self._axis is not None: 

589 # remove the axis from the rendering batch 

590 self._axis.delete() 

591 # set the reference to None 

592 self._axis = None 

593 

594 if self.view["grid"] and self._grid is None: 

595 try: 

596 # create a grid marker 

597 from ..path.creation import grid 

598 

599 bounds = self.scene.bounds 

600 center = bounds.mean(axis=0) 

601 # set the grid to the lowest Z position 

602 # also offset by the scale to avoid interference 

603 center[2] = bounds[0][2] - (np.ptp(bounds[:, 2]) / 100) 

604 # choose the side length by maximum XY length 

605 side = np.ptp(bounds, axis=0)[:2].max() 

606 # create an axis marker sized relative to the scene 

607 grid_mesh = grid(side=side, count=4, transform=translation_matrix(center)) 

608 # convert the path to vertexlist args 

609 args = rendering.convert_to_vertexlist(grid_mesh) 

610 # create ordered args for a vertex list 

611 self._grid = self.batch.add_indexed(*args) 

612 except BaseException: 

613 util.log.warning("failed to create grid!", exc_info=True) 

614 elif not self.view["grid"] and self._grid is not None: 

615 self._grid.delete() 

616 self._grid = None 

617 

618 def _update_perspective(self, width, height): 

619 try: 

620 # for high DPI screens viewport size 

621 # will be different then the passed size 

622 width, height = self.get_viewport_size() 

623 except BaseException: 

624 # older versions of pyglet may not have this 

625 pass 

626 

627 # set the new viewport size 

628 gl.glViewport(0, 0, width, height) 

629 gl.glMatrixMode(gl.GL_PROJECTION) 

630 gl.glLoadIdentity() 

631 

632 # get field of view and Z range from camera 

633 camera = self.scene.camera 

634 

635 # set perspective from camera data 

636 gl.gluPerspective( 

637 camera.fov[1], width / float(height), camera.z_near, camera.z_far 

638 ) 

639 gl.glMatrixMode(gl.GL_MODELVIEW) 

640 

641 return width, height 

642 

643 def on_resize(self, width, height): 

644 """ 

645 Handle resized windows. 

646 """ 

647 width, height = self._update_perspective(width, height) 

648 self.scene.camera.resolution = (width, height) 

649 self.view["ball"].resize(self.scene.camera.resolution) 

650 self.scene.camera_transform = self.view["ball"].pose 

651 

652 def on_mouse_press(self, x, y, buttons, modifiers): 

653 """ 

654 Set the start point of the drag. 

655 """ 

656 self.view["ball"].set_state(Trackball.STATE_ROTATE) 

657 if buttons == pyglet.window.mouse.LEFT: 

658 ctrl = modifiers & pyglet.window.key.MOD_CTRL 

659 shift = modifiers & pyglet.window.key.MOD_SHIFT 

660 if ctrl and shift: 

661 self.view["ball"].set_state(Trackball.STATE_ZOOM) 

662 elif shift: 

663 self.view["ball"].set_state(Trackball.STATE_ROLL) 

664 elif ctrl: 

665 self.view["ball"].set_state(Trackball.STATE_PAN) 

666 elif buttons == pyglet.window.mouse.MIDDLE: 

667 self.view["ball"].set_state(Trackball.STATE_PAN) 

668 elif buttons == pyglet.window.mouse.RIGHT: 

669 self.view["ball"].set_state(Trackball.STATE_ZOOM) 

670 

671 self.view["ball"].down(np.array([x, y])) 

672 self.scene.camera_transform = self.view["ball"].pose 

673 

674 def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers): 

675 """ 

676 Pan or rotate the view. 

677 """ 

678 self.view["ball"].drag(np.array([x, y])) 

679 self.scene.camera_transform = self.view["ball"].pose 

680 

681 def on_mouse_scroll(self, x, y, dx, dy): 

682 """ 

683 Zoom the view. 

684 """ 

685 self.view["ball"].scroll(dy) 

686 self.scene.camera_transform = self.view["ball"].pose 

687 

688 def on_key_press(self, symbol, modifiers): 

689 """ 

690 Call appropriate functions given key presses. 

691 """ 

692 magnitude = 10 

693 if symbol == pyglet.window.key.W: 

694 self.toggle_wireframe() 

695 elif symbol == pyglet.window.key.Z: 

696 self.reset_view() 

697 elif symbol == pyglet.window.key.C: 

698 self.toggle_culling() 

699 elif symbol == pyglet.window.key.A: 

700 self.toggle_axis() 

701 elif symbol == pyglet.window.key.G: 

702 self.toggle_grid() 

703 elif symbol == pyglet.window.key.Q: 

704 self.on_close() 

705 elif symbol == pyglet.window.key.M: 

706 self.maximize() 

707 elif symbol == pyglet.window.key.F: 

708 self.toggle_fullscreen() 

709 elif symbol == pyglet.window.key.H: 

710 print(_HELP_MESSAGE) # noqa: T201 

711 

712 if symbol in [ 

713 pyglet.window.key.LEFT, 

714 pyglet.window.key.RIGHT, 

715 pyglet.window.key.DOWN, 

716 pyglet.window.key.UP, 

717 ]: 

718 self.view["ball"].down([0, 0]) 

719 if symbol == pyglet.window.key.LEFT: 

720 self.view["ball"].drag([-magnitude, 0]) 

721 elif symbol == pyglet.window.key.RIGHT: 

722 self.view["ball"].drag([magnitude, 0]) 

723 elif symbol == pyglet.window.key.DOWN: 

724 self.view["ball"].drag([0, -magnitude]) 

725 elif symbol == pyglet.window.key.UP: 

726 self.view["ball"].drag([0, magnitude]) 

727 self.scene.camera_transform = self.view["ball"].pose 

728 

729 def on_draw(self): 

730 """ 

731 Run the actual draw calls. 

732 """ 

733 

734 if self._profile: 

735 profiler = self.Profiler() 

736 profiler.start() 

737 

738 self._update_meshes() 

739 gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) 

740 gl.glLoadIdentity() 

741 

742 # pull the new camera transform from the scene 

743 transform_camera = np.linalg.inv(self.scene.camera_transform) 

744 

745 # apply the camera transform to the matrix stack 

746 gl.glMultMatrixf(rendering.matrix_to_gl(transform_camera)) 

747 

748 # we want to render fully opaque objects first, 

749 # followed by objects which have transparency 

750 node_names = collections.deque(self.scene.graph.nodes_geometry) 

751 # how many nodes did we start with 

752 count_original = len(node_names) 

753 count = -1 

754 

755 # if we are rendering an axis marker at the world 

756 if self._axis and not self.view["axis"] == "without_world": 

757 # we stored it as a vertex list 

758 self._axis.draw(mode=gl.GL_TRIANGLES) 

759 if self._grid: 

760 self._grid.draw(mode=gl.GL_LINES) 

761 

762 # save a reference outside of the loop 

763 geometry = self.scene.geometry 

764 graph = self.scene.graph 

765 

766 while len(node_names) > 0: 

767 count += 1 

768 current_node = node_names.popleft() 

769 

770 if current_node in self._nodes_hidden: 

771 continue 

772 

773 # get the transform from world to geometry and mesh name 

774 transform, geometry_name = graph.get(current_node) 

775 # if no geometry at this frame continue without rendering 

776 if geometry_name is None or geometry_name not in self.vertex_list_mode: 

777 continue 

778 

779 # if a geometry is marked as fixed apply the inverse view transform 

780 if self.fixed is not None and geometry_name in self.fixed: 

781 # remove altered camera transform from fixed geometry 

782 transform_fix = np.linalg.inv( 

783 np.dot(self._initial_camera_transform, transform_camera) 

784 ) 

785 # apply the transform so the fixed geometry doesn't move 

786 transform = np.dot(transform, transform_fix) 

787 

788 # get a reference to the mesh so we can check transparency 

789 mesh = geometry[geometry_name] 

790 if mesh.is_empty: 

791 continue 

792 # get the GL mode of the current geometry 

793 mode = self.vertex_list_mode[geometry_name] 

794 

795 # if you draw a coplanar line with a triangle it will z-fight 

796 # the best way to do this is probably a shader but this works fine 

797 if mode == gl.GL_LINES: 

798 # apply the offset in camera space 

799 transform = util.multi_dot( 

800 [ 

801 transform, 

802 np.linalg.inv(transform_camera), 

803 self._line_offset, 

804 transform_camera, 

805 ] 

806 ) 

807 

808 # add a new matrix to the model stack 

809 gl.glPushMatrix() 

810 # transform by the nodes transform 

811 gl.glMultMatrixf(rendering.matrix_to_gl(transform)) 

812 

813 # draw an axis marker for each mesh frame 

814 if self.view["axis"] == "all": 

815 self._axis.draw(mode=gl.GL_TRIANGLES) 

816 elif self.view["axis"] == "without_world": 

817 if not util.allclose(transform, np.eye(4), atol=1e-5): 

818 self._axis.draw(mode=gl.GL_TRIANGLES) 

819 

820 # transparent things must be drawn last 

821 if ( 

822 hasattr(mesh, "visual") 

823 and hasattr(mesh.visual, "transparency") 

824 and mesh.visual.transparency 

825 ): 

826 # put the current item onto the back of the queue 

827 if count < count_original: 

828 # add the node to be drawn last 

829 node_names.append(current_node) 

830 # pop the matrix stack for now 

831 gl.glPopMatrix() 

832 # come back to this mesh later 

833 continue 

834 

835 # if we have texture enable the target texture 

836 texture = None 

837 if geometry_name in self.textures: 

838 texture = self.textures[geometry_name] 

839 gl.glEnable(texture.target) 

840 gl.glBindTexture(texture.target, texture.id) 

841 

842 # draw the mesh with its transform applied 

843 self.vertex_list[geometry_name].draw(mode=mode) 

844 # pop the matrix stack as we drew what we needed to draw 

845 gl.glPopMatrix() 

846 

847 # disable texture after using 

848 if texture is not None: 

849 gl.glDisable(texture.target) 

850 

851 if self._profile: 

852 profiler.stop() 

853 util.log.debug(profiler.output_text(unicode=True, color=True)) 

854 

855 def flip(self): 

856 super().flip() 

857 if self._record: 

858 # will save a PNG-encoded bytes 

859 img = self.save_image(util.BytesIO()) 

860 # seek start of file-like object 

861 img.seek(0) 

862 # save the bytes from the file object 

863 self.scene.metadata["recording"].append(img.read()) 

864 

865 def save_image(self, file_obj): 

866 """ 

867 Save the current color buffer to a file object 

868 in PNG format. 

869 

870 Parameters 

871 ------------- 

872 file_obj: file name, or file- like object 

873 """ 

874 manager = pyglet.image.get_buffer_manager() 

875 colorbuffer = manager.get_color_buffer() 

876 # if passed a string save by name 

877 if hasattr(file_obj, "write"): 

878 colorbuffer.save(file=file_obj) 

879 else: 

880 colorbuffer.save(filename=file_obj) 

881 return file_obj 

882 

883 

884def _geometry_hash(geometry): 

885 """ 

886 Get a hash for a geometry object 

887 

888 Parameters 

889 ------------ 

890 geometry : object 

891 

892 Returns 

893 ------------ 

894 hash : str 

895 """ 

896 h = str(hash(geometry)) 

897 if hasattr(geometry, "visual"): 

898 # if visual properties are defined 

899 h += str(hash(geometry.visual)) 

900 

901 return h 

902 

903 

904def render_scene( 

905 scene, resolution=None, visible=True, fullscreen=False, resizable=True, **kwargs 

906): 

907 """ 

908 Render a preview of a scene to a PNG. Note that 

909 whether this works or not highly variable based on 

910 platform and graphics driver. 

911 

912 Parameters 

913 ------------ 

914 scene : trimesh.Scene 

915 Geometry to be rendered 

916 resolution : (2,) int or None 

917 Resolution in pixels or set from scene.camera 

918 visible : bool 

919 Show a window during rendering. Note that MANY 

920 platforms refuse to render with hidden windows 

921 and will likely return a blank image; this is a 

922 platform issue and cannot be fixed in Python. 

923 fullscreen : bool 

924 Determines whether the window is rendered in fullscreen mode. 

925 Defaults to False (windowed). 

926 resizable : bool 

927 Determines whether the rendered window can be resized by the user. 

928 Defaults to True (resizable). 

929 kwargs : ** 

930 Passed to SceneViewer 

931 

932 Returns 

933 --------- 

934 render : bytes 

935 Image in PNG format 

936 """ 

937 window = SceneViewer( 

938 scene, 

939 start_loop=False, 

940 visible=visible, 

941 resolution=resolution, 

942 fullscreen=fullscreen, 

943 resizable=resizable, 

944 **kwargs, 

945 ) 

946 

947 from ..util import BytesIO 

948 

949 # need to run loop twice to display anything 

950 for save in [False, False, True]: 

951 pyglet.clock.tick() 

952 window.switch_to() 

953 window.dispatch_events() 

954 window.dispatch_event("on_draw") 

955 window.flip() 

956 if save: 

957 # save the color buffer data to memory 

958 file_obj = BytesIO() 

959 window.save_image(file_obj) 

960 file_obj.seek(0) 

961 render = file_obj.read() 

962 window.close() 

963 

964 return render