OpenSceneGraph 0.1.2__cp313-cp313-win_amd64.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.
Files changed (90) hide show
  1. OpenSceneGraph/OpenThreads.lib +0 -0
  2. OpenSceneGraph/_OpenSceneGraph.cp313-win_amd64.pyd +0 -0
  3. OpenSceneGraph/__init__.py +52 -0
  4. OpenSceneGraph/aipython/00-index.md +44 -0
  5. OpenSceneGraph/aipython/01-core.md +255 -0
  6. OpenSceneGraph/aipython/02-inspect.md +81 -0
  7. OpenSceneGraph/aipython/03-headless-frames.md +149 -0
  8. OpenSceneGraph/aipython/05-camera-manipulator.md +68 -0
  9. OpenSceneGraph/aipython/06-camera-effects.md +132 -0
  10. OpenSceneGraph/aipython/07-camera-manual.md +88 -0
  11. OpenSceneGraph/aipython/08-lighting.md +121 -0
  12. OpenSceneGraph/aipython/09-picking.md +90 -0
  13. OpenSceneGraph/aipython/10-rtt.md +199 -0
  14. OpenSceneGraph/aipython/11-mrt.md +104 -0
  15. OpenSceneGraph/aipython/12-gbuffer.md +48 -0
  16. OpenSceneGraph/aipython/15-shader-hotswap.md +72 -0
  17. OpenSceneGraph/aipython/17-particles.md +189 -0
  18. OpenSceneGraph/aipython/18-deterministic-captures.md +157 -0
  19. OpenSceneGraph/aipython/20-object-lifetime.md +77 -0
  20. OpenSceneGraph/aipython/25-async-osgpy.md +228 -0
  21. OpenSceneGraph/aipython/29-material.md +109 -0
  22. OpenSceneGraph/aipython/30-pbribl.md +162 -0
  23. OpenSceneGraph/aipython/40-typed-lights-gizmos.md +218 -0
  24. OpenSceneGraph/examples/__init__.py +24 -0
  25. OpenSceneGraph/examples/__main__.py +143 -0
  26. OpenSceneGraph/examples/blur.py +370 -0
  27. OpenSceneGraph/examples/info.py +126 -0
  28. OpenSceneGraph/examples/mrt.py +493 -0
  29. OpenSceneGraph/examples/pyosg_async.py +467 -0
  30. OpenSceneGraph/examples/pyosg_example.py +120 -0
  31. OpenSceneGraph/examples/pyosg_repl.py +842 -0
  32. OpenSceneGraph/examples/pyosg_visitor.py +138 -0
  33. OpenSceneGraph/ktx.dll +0 -0
  34. OpenSceneGraph/ktx.lib +0 -0
  35. OpenSceneGraph/osg.lib +0 -0
  36. OpenSceneGraph/osg161-osg.dll +0 -0
  37. OpenSceneGraph/osg161-osgAnimation.dll +0 -0
  38. OpenSceneGraph/osg161-osgDB.dll +0 -0
  39. OpenSceneGraph/osg161-osgFX.dll +0 -0
  40. OpenSceneGraph/osg161-osgGA.dll +0 -0
  41. OpenSceneGraph/osg161-osgText.dll +0 -0
  42. OpenSceneGraph/osg161-osgUtil.dll +0 -0
  43. OpenSceneGraph/osg161-osgViewer.dll +0 -0
  44. OpenSceneGraph/osg161-osgWidget.dll +0 -0
  45. OpenSceneGraph/osgAnimation.lib +0 -0
  46. OpenSceneGraph/osgDB.lib +0 -0
  47. OpenSceneGraph/osgFX.lib +0 -0
  48. OpenSceneGraph/osgGA.lib +0 -0
  49. OpenSceneGraph/osgPlugins-3.6.5/jpeg62-ebb2f26be87097e77bafd1e9095b4820.dll +0 -0
  50. OpenSceneGraph/osgPlugins-3.6.5/ktx.dll +0 -0
  51. OpenSceneGraph/osgPlugins-3.6.5/liblzma-b9cff3753c4848b9ff1350ba5a053c6e.dll +0 -0
  52. OpenSceneGraph/osgPlugins-3.6.5/libpng16-36b7e1e8d185cb21280e5688f99660c0.dll +0 -0
  53. OpenSceneGraph/osgPlugins-3.6.5/msvcp140.dll +0 -0
  54. OpenSceneGraph/osgPlugins-3.6.5/osgdb_bmp.dll +0 -0
  55. OpenSceneGraph/osgPlugins-3.6.5/osgdb_dds.dll +0 -0
  56. OpenSceneGraph/osgPlugins-3.6.5/osgdb_gltf.dll +0 -0
  57. OpenSceneGraph/osgPlugins-3.6.5/osgdb_hdr.dll +0 -0
  58. OpenSceneGraph/osgPlugins-3.6.5/osgdb_jpeg.dll +0 -0
  59. OpenSceneGraph/osgPlugins-3.6.5/osgdb_ktx2.dll +0 -0
  60. OpenSceneGraph/osgPlugins-3.6.5/osgdb_obj.dll +0 -0
  61. OpenSceneGraph/osgPlugins-3.6.5/osgdb_osg.dll +0 -0
  62. OpenSceneGraph/osgPlugins-3.6.5/osgdb_png.dll +0 -0
  63. OpenSceneGraph/osgPlugins-3.6.5/osgdb_pnm.dll +0 -0
  64. OpenSceneGraph/osgPlugins-3.6.5/osgdb_rgb.dll +0 -0
  65. OpenSceneGraph/osgPlugins-3.6.5/osgdb_serializers_osg.dll +0 -0
  66. OpenSceneGraph/osgPlugins-3.6.5/osgdb_stl.dll +0 -0
  67. OpenSceneGraph/osgPlugins-3.6.5/osgdb_tga.dll +0 -0
  68. OpenSceneGraph/osgPlugins-3.6.5/osgdb_tiff.dll +0 -0
  69. OpenSceneGraph/osgPlugins-3.6.5/tiff-1defa8059e5ba115a3ab4b120de6f4bb.dll +0 -0
  70. OpenSceneGraph/osgPlugins-3.6.5/z.dll +0 -0
  71. OpenSceneGraph/osgText.lib +0 -0
  72. OpenSceneGraph/osgUtil.lib +0 -0
  73. OpenSceneGraph/osgViewer.lib +0 -0
  74. OpenSceneGraph/osgWidget.lib +0 -0
  75. OpenSceneGraph/osgx.cp313-win_amd64.pyd +0 -0
  76. OpenSceneGraph/osgx_static.lib +0 -0
  77. OpenSceneGraph/ot21-OpenThreads.dll +0 -0
  78. openscenegraph-0.1.2.dist-info/DELVEWHEEL +2 -0
  79. openscenegraph-0.1.2.dist-info/METADATA +563 -0
  80. openscenegraph-0.1.2.dist-info/RECORD +90 -0
  81. openscenegraph-0.1.2.dist-info/WHEEL +5 -0
  82. openscenegraph-0.1.2.dist-info/entry_points.txt +6 -0
  83. openscenegraph-0.1.2.dist-info/licenses/LICENSE +21 -0
  84. openscenegraph.libs/jpeg62-ebb2f26be87097e77bafd1e9095b4820.dll +0 -0
  85. openscenegraph.libs/liblzma-b9cff3753c4848b9ff1350ba5a053c6e.dll +0 -0
  86. openscenegraph.libs/libpng16-36b7e1e8d185cb21280e5688f99660c0.dll +0 -0
  87. openscenegraph.libs/msvcp140.dll +0 -0
  88. openscenegraph.libs/tiff-1defa8059e5ba115a3ab4b120de6f4bb.dll +0 -0
  89. openscenegraph.libs/z.dll +0 -0
  90. osgx.py +7 -0
@@ -0,0 +1,842 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Proof-of-concept for an eventual OpenSceneGraph.repl(viewer, namespace) helper.
4
+ #
5
+ # Unlike the older version of this example, this does NOT need to be launched by
6
+ # `ipython3 -i` and does not register a custom OSG prompt input hook. It embeds a
7
+ # directly-configured IPython shell and schedules viewer.frame() on IPython's own
8
+ # asyncio event loop. The OSG window therefore keeps rendering and responding while
9
+ # IPython is idle at its prompt, and top-level `await` remains available.
10
+ #
11
+ # The slightly non-obvious setup was proven first in pyosg-lighting/11-sketchfab.py:
12
+ #
13
+ # * `IPython.embed(using="asyncio")` is insufficient: it selects the runner for
14
+ # top-level await, but does not make the prompt itself pump asyncio while idle.
15
+ # * shell.enable_gui("asyncio") enables that separate prompt input hook.
16
+ # * the IPython.embed() convenience function clears any preconfigured singleton,
17
+ # so we must construct InteractiveShellEmbed and invoke it directly.
18
+ #
19
+ # Try these at the prompt while continuing to manipulate the viewer window:
20
+ #
21
+ # viewer.camera.clearColor = osg.Vec4(0.15, 0.05, 0.2, 1)
22
+ # scene.nodeMask = 0; await asyncio.sleep(1); scene.nodeMask = 0xffffffff
23
+ # n = _osg_repl_state["frames"]
24
+ # await asyncio.sleep(1); _osg_repl_state["frames"] - n
25
+ # await _osg_repl_controller.capture_framebuffer("frame.png") # raw framebuffer PNG
26
+ # _osg_repl_controls.input.locked = True # temporarily block mouse/keyboard input
27
+ # _osg_repl_controls.frames.target_fps = 30 # best-effort viewer pacing
28
+ #
29
+ # Type `exit` or Ctrl-D to leave the REPL and close the viewer.
30
+
31
+ import os
32
+ import sys
33
+
34
+ # setdefault(), not update() -- this module is imported by other examples
35
+ # (11-sketchfab.py, 99-repl.py, etc.) that configure their own OSG_WINDOW/
36
+ # OSG_THREADING before importing pyosg_repl for its repl() helper. update()
37
+ # here would silently clobber whatever the caller already set, since this
38
+ # import (inside the caller's `if args.repl:` branch) runs well before
39
+ # viewer.realize() actually reads the env var on the first frame(). Only
40
+ # fill these in for pyosg_repl.py's own standalone `__main__` use below,
41
+ # where nothing else has set them yet.
42
+ os.environ.setdefault("OSG_WINDOW", "50 50 800 600")
43
+ os.environ.setdefault("OSG_THREADING", "SingleThreaded")
44
+
45
+ import asyncio
46
+ import contextlib
47
+ import math
48
+ import subprocess
49
+ import time
50
+ import traceback
51
+
52
+ from aipython.integration import MainLoopController, drive
53
+
54
+ from OpenSceneGraph import *
55
+ from OpenSceneGraph.GL import *
56
+
57
+ class DebugHandler(osgGA.GUIEventHandler):
58
+ debug = False
59
+
60
+ def handle(self, ea, aa):
61
+ if self.debug:
62
+ if not ea.type == osgGA.GUIEventAdapter.FRAME:
63
+ print(f"DebugHandler.handle(self={self}, ea={ea}, aa={aa}) type={ea.type}")
64
+
65
+ class AgentInputLock(osgGA.GUIEventHandler):
66
+ """Swallows user input (mouse/keyboard) while locked, so an agent driving a live viewer
67
+ from the REPL can do deterministic work -- read/write uniforms, pose the camera, queue a
68
+ capture -- without racing the user's own mouse/keyboard on the same window.
69
+
70
+ The race this exists for: a user keypress handled by some other GUIEventHandler (e.g. a
71
+ "retrigger the effect" key) landing on the same frame as the agent independently reading
72
+ or writing state through the REPL -- two uncoordinated control sources on one live viewer,
73
+ each individually correct, racing each other.
74
+
75
+ FRAME/RESIZE/CLOSE_WINDOW/etc. always pass through even while locked, so continuous
76
+ rendering and any handler that steps its own animation off FRAME keep working -- only
77
+ events a human could have generated at the keyboard/mouse are swallowed.
78
+
79
+ IMPORTANT: this is COOPERATIVE, not enforced -- OSG's own eventHandlers dispatch loop
80
+ (Viewer::eventTraversal()) calls every handler for every event regardless of what
81
+ earlier handlers returned; there is no "stop propagation" mechanism at that level.
82
+ What actually gates things is `osgGA::Event.handled`: `GUIEventHandler::handle()`'s C++
83
+ wrapper (the one OSG's loop actually calls) sets `ea.handled = True` whenever your
84
+ Python `handle(ea, aa)` override returns `True`, and every WELL-BEHAVED handler after
85
+ it is expected to check `ea.handled` at the top of its own `handle()` and bail out if
86
+ it's already set -- exactly how OSG's own `StandardManipulator` (the base of
87
+ `TrackballManipulator` and friends) already does it, which is why camera-manipulator
88
+ input is blocked automatically with zero extra code. A handler that doesn't check
89
+ `ea.handled` (an old example script's own custom handler, say) will keep firing
90
+ regardless of this lock -- there's no way to force it from here, only to follow the
91
+ same convention it should already be following. `insert()`-installed at
92
+ `eventHandlers[0]` only guarantees this handler runs, and therefore sets `ea.handled`,
93
+ BEFORE any other handler in the list sees the event -- it doesn't make later handlers
94
+ respect that flag if they were never written to check it.
95
+
96
+ `ViewerREPLController` installs one of these at `eventHandlers[0]` (first refusal, ahead
97
+ of whatever handlers the caller already appended) and exposes it via `.lock_input()`/
98
+ `.unlock_input()`/`.locked_input()` rather than needing to be constructed directly.
99
+ """
100
+
101
+ _INPUT_EVENTS = frozenset((
102
+ osgGA.GUIEventAdapter.PUSH,
103
+ osgGA.GUIEventAdapter.RELEASE,
104
+ osgGA.GUIEventAdapter.DOUBLECLICK,
105
+ osgGA.GUIEventAdapter.DRAG,
106
+ osgGA.GUIEventAdapter.MOVE,
107
+ osgGA.GUIEventAdapter.SCROLL,
108
+ osgGA.GUIEventAdapter.KEYDOWN,
109
+ osgGA.GUIEventAdapter.KEYUP,
110
+ ))
111
+
112
+ def __init__(self):
113
+ super().__init__()
114
+
115
+ self.locked = False
116
+
117
+ def handle(self, ea, aa):
118
+ return self.locked and ea.type in self._INPUT_EVENTS
119
+
120
+ class REPLCameraManipulator(osgGA.CameraManipulator):
121
+ def __init__(self):
122
+ super().__init__()
123
+
124
+ self._matrix = osg.Matrixd()
125
+
126
+ def getMatrix(self):
127
+ return self._matrix
128
+
129
+ def setByMatrix(self, m):
130
+ self._matrix = m
131
+
132
+ def getInverseMatrix(self):
133
+ return osg.Matrix.inverse(self._matrix)
134
+
135
+ def setByInverseMatrix(self, m):
136
+ self._matrix = osg.Matrix.inverse(m)
137
+
138
+ class CinematicOrbitManipulator(osgGA.CameraManipulator):
139
+ """Algorithmically-driven "camera drone" orbit around the loaded model.
140
+
141
+ Unlike REPLCameraManipulator above (a passive matrix box you pose by hand),
142
+ this one poses itself: getMatrix()/getInverseMatrix() are computed on every
143
+ call from wall-clock time, so there's nothing to step or drive from the REPL
144
+ loop -- just assign it and watch.
145
+
146
+ Two out-of-phase sinusoids drive azimuth (constant spin) and elevation (bob
147
+ above/below a base pitch); a third drives a zoom "breathe" whose far point is
148
+ clamped, every frame, to whatever distance keeps the model's bounding sphere
149
+ at *least* `min_screen_fraction` of the smaller screen dimension -- so it can
150
+ never drift out to a speck regardless of model size or window shape. The
151
+ look-at point is always the model's own bounding-sphere center: it never
152
+ drifts, so the model stays framed dead-center throughout. Assumes a Z-up
153
+ scene, matching OSG's default and the up-axis correction OSG's own loaders
154
+ (glTF, etc.) already apply.
155
+
156
+ Needs the live camera (not just its projection matrix at construction time)
157
+ because the projection changes on window resize and the framing constraint
158
+ has to track that.
159
+
160
+ Overrides setNode()/getNode() to store the scene node into self._node, and
161
+ home() to (re)compute the bounding sphere from it -- both of these used to
162
+ be broken in the pybind11 bindings (CameraManipulator's trampoline didn't
163
+ intercept setNode()/getNode() at all, so a Python override of either was
164
+ silently never called; and home()'s two-argument overload crashed trying
165
+ to copy a non-copyable GUIEventAdapter when forwarding to a Python
166
+ override). Both are fixed now, so this no longer needs the
167
+ node-as-constructor-argument workaround an earlier version of this class
168
+ used. Note the trampoline fix only makes overriding setNode()/getNode()
169
+ *possible* -- it doesn't give a subclass free storage for free; a
170
+ subclass that doesn't override them still gets the C++ base class's
171
+ no-op defaults, same as any plain C++ CameraManipulator subclass would.
172
+
173
+ Try it live from the aipython REPL:
174
+
175
+ viewer.cameraManipulator = CinematicOrbitManipulator(viewer.camera)
176
+ """
177
+
178
+ def __init__(
179
+ self,
180
+ camera,
181
+ azimuth_speed=0.3,
182
+ elevation_speed=0.45,
183
+ elevation_center=0.3,
184
+ elevation_amplitude=0.6,
185
+ distance_speed=0.35,
186
+ distance_min_fraction=0.6,
187
+ min_screen_fraction=0.5,
188
+ ):
189
+ super().__init__()
190
+
191
+ self.camera = camera
192
+ self.azimuth_speed = azimuth_speed
193
+ self.elevation_speed = elevation_speed
194
+ self.elevation_center = elevation_center
195
+ self.elevation_amplitude = elevation_amplitude
196
+ self.distance_speed = distance_speed
197
+ self.distance_min_fraction = distance_min_fraction
198
+ self.min_screen_fraction = min_screen_fraction
199
+
200
+ self._node = None
201
+ self._start = time.monotonic()
202
+ self._center = osg.Vec3d(0, 0, 0)
203
+ self._radius = 1.0
204
+
205
+ def setNode(self, node):
206
+ self._node = node
207
+
208
+ def getNode(self):
209
+ return self._node
210
+
211
+ def home(self, *args):
212
+ # Called by View.setCameraManipulator(..., resetPosition=True) -- i.e. as
213
+ # soon as `viewer.cameraManipulator = self` runs -- with self._node
214
+ # already populated (setNode() always runs first), so the model's real
215
+ # size/position is available here rather than needing to be guessed at
216
+ # construction time.
217
+ bound = self._node.bound if self._node is not None else None
218
+
219
+ if bound is not None and bound.valid():
220
+ self._center = osg.Vec3d(bound.center)
221
+ self._radius = max(bound.radius, 1e-3)
222
+ else:
223
+ self._center = osg.Vec3d(0, 0, 0)
224
+ self._radius = 1.0
225
+
226
+ self._start = time.monotonic()
227
+
228
+ def _max_distance(self):
229
+ # The bounding sphere's angular radius as seen from distance d is
230
+ # asin(radius / d); solving for the distance at which its angular
231
+ # *diameter* equals min_screen_fraction of a given axis' FOV gives the
232
+ # farthest the camera can sit and still satisfy that axis. The axis
233
+ # with the larger FOV is the binding (smaller-max-distance) one, so
234
+ # using max(fovy, fovx) here guarantees the floor holds on both axes.
235
+ fovy_deg, aspect, _near, _far = self.camera.projectionMatrix.getPerspective()
236
+
237
+ fovy_rad = math.radians(fovy_deg)
238
+ fovx_rad = 2.0 * math.atan(aspect * math.tan(fovy_rad * 0.5))
239
+ limiting_fov = max(fovy_rad, fovx_rad)
240
+
241
+ return self._radius / math.sin(0.5 * self.min_screen_fraction * limiting_fov)
242
+
243
+ def _pose(self):
244
+ t = time.monotonic() - self._start
245
+
246
+ azimuth = t * self.azimuth_speed
247
+ elevation = self.elevation_center + self.elevation_amplitude * math.sin(
248
+ t * self.elevation_speed
249
+ )
250
+
251
+ max_distance = self._max_distance()
252
+ zoom_t = 0.5 + 0.5 * math.sin(t * self.distance_speed)
253
+ distance = max_distance * (1.0 - (1.0 - self.distance_min_fraction) * zoom_t)
254
+
255
+ cos_el = math.cos(elevation)
256
+
257
+ offset = osg.Vec3d(
258
+ math.cos(azimuth) * cos_el,
259
+ math.sin(azimuth) * cos_el,
260
+ math.sin(elevation),
261
+ ) * distance
262
+
263
+ eye = self._center + offset
264
+
265
+ return eye, self._center, osg.Vec3d(0, 0, 1)
266
+
267
+ def getMatrix(self):
268
+ eye, center, up = self._pose()
269
+
270
+ return osg.Matrix.inverse(osg.Matrix.lookAt(eye, center, up))
271
+
272
+ def setByMatrix(self, m):
273
+ pass
274
+
275
+ def getInverseMatrix(self):
276
+ eye, center, up = self._pose()
277
+
278
+ return osg.Matrix.lookAt(eye, center, up)
279
+
280
+ def setByInverseMatrix(self, m):
281
+ pass
282
+
283
+ # --------------------------------------------------------------------------- #
284
+ # Gathered reusable implementation
285
+ # --------------------------------------------------------------------------- #
286
+
287
+ class CaptureRequest:
288
+ """Awaitable result of a capture queued for the next rendered frame."""
289
+
290
+ def __init__(self, kind, filename=None, label=None, include_image=False):
291
+ self.kind = kind
292
+ self.filename = filename
293
+ self.label = label
294
+ self.include_image = include_image
295
+ self._future = asyncio.get_event_loop().create_future()
296
+
297
+ def __await__(self):
298
+ return self._future.__await__()
299
+
300
+ @property
301
+ def done(self):
302
+ return self._future.done()
303
+
304
+ def result(self):
305
+ return self._future.result()
306
+
307
+
308
+ class CaptureQueueCallback(osg.Camera.DrawCallback):
309
+ """Persistent final-draw callback which drains one same-frame capture batch."""
310
+
311
+ def __init__(self, controller):
312
+ super().__init__()
313
+
314
+ self.controller = controller
315
+
316
+ def __call__(self, ri):
317
+ # Move the queue before executing it. Captures queued by completion handlers
318
+ # therefore belong to the following frame, never this partially-drained batch.
319
+ batch = self.controller._capture_queue
320
+ self.controller._capture_queue = []
321
+
322
+ for request, texture, data_type in batch:
323
+ try:
324
+ if request.kind == "framebuffer":
325
+ image = self._read_framebuffer()
326
+
327
+ else:
328
+ image = self._read_texture(ri, texture, data_type)
329
+
330
+ if request.filename is not None:
331
+ if not osgDB.writeImageFile(image, request.filename):
332
+ raise RuntimeError(f"failed to write capture {request.filename!r}")
333
+
334
+ result = self.controller._capture_metadata(
335
+ request, image, data_type,
336
+ )
337
+
338
+ if request.include_image:
339
+ result["image"] = image
340
+
341
+ request._future.set_result(result)
342
+
343
+ if request.filename is not None:
344
+ print(f"Wrote {request.kind}: {request.filename}", flush=True)
345
+
346
+ except Exception as exc:
347
+ request._future.set_exception(exc)
348
+ traceback.print_exc()
349
+
350
+ video = self.controller._video_capture
351
+
352
+ if video is not None:
353
+ video.capture(self)
354
+
355
+ def _read_framebuffer(self):
356
+ vp = self.controller.viewer.camera.viewport
357
+ if vp is None or not vp.valid:
358
+ raise RuntimeError("capture camera has no valid viewport")
359
+
360
+ image = osg.Image()
361
+
362
+ image.readPixels(
363
+ int(vp.x), int(vp.y), int(vp.width), int(vp.height),
364
+ GL_RGB, GL_UNSIGNED_BYTE,
365
+ )
366
+
367
+ return image
368
+
369
+ @staticmethod
370
+ def _read_texture(ri, texture, data_type):
371
+ texture.apply(ri.state)
372
+
373
+ image = osg.Image()
374
+
375
+ image.readImageFromCurrentTexture(ri.contextID, False, data_type)
376
+
377
+ return image
378
+
379
+
380
+ class VideoCapture:
381
+ """Stream timed framebuffer captures directly to an FFmpeg process."""
382
+
383
+ def __init__(self, controller, filename, fps=24, duration=5, lock_input=False):
384
+ if not isinstance(filename, str) or not filename:
385
+ raise ValueError("video filename must be a non-empty str")
386
+
387
+ if not isinstance(fps, (int, float)) or isinstance(fps, bool) or fps <= 0:
388
+ raise ValueError("video fps must be positive")
389
+
390
+ if not isinstance(duration, (int, float)) or isinstance(duration, bool) or duration <= 0:
391
+ raise ValueError("video duration must be positive")
392
+
393
+ if not isinstance(lock_input, bool):
394
+ raise TypeError("video lock_input must be a bool")
395
+
396
+ viewport = controller.viewer.camera.viewport
397
+
398
+ if viewport is None or not viewport.valid:
399
+ raise RuntimeError("video capture camera has no valid viewport")
400
+
401
+ self.controller = controller
402
+ self.filename = filename
403
+ self.fps = float(fps)
404
+ self.duration = float(duration)
405
+ self.width = int(viewport.width)
406
+ self.height = int(viewport.height)
407
+ self.frame_count = int(round(self.fps * self.duration))
408
+ self.captured_frames = 0
409
+ self.started_at = time.monotonic()
410
+ self.next_frame_at = self.started_at
411
+ self._input_was_locked = controller.input_locked
412
+ self._lock_input = lock_input
413
+ self._finished = False
414
+ self._error = None
415
+ self.process = subprocess.Popen([
416
+ "ffmpeg", "-y",
417
+ "-f", "rawvideo",
418
+ "-pixel_format", "rgb24",
419
+ "-video_size", f"{self.width}x{self.height}",
420
+ "-framerate", str(self.fps),
421
+ "-i", "pipe:0",
422
+ "-vf", "vflip",
423
+ "-c:v", "libx264",
424
+ "-pix_fmt", "yuv420p",
425
+ filename,
426
+ ], stdin=subprocess.PIPE, bufsize=0)
427
+
428
+ if lock_input:
429
+ controller.lock_input("Recording video")
430
+
431
+ @property
432
+ def active(self):
433
+ return not self._finished
434
+
435
+ @property
436
+ def status(self):
437
+ return {
438
+ "filename": self.filename,
439
+ "fps": self.fps,
440
+ "duration": self.duration,
441
+ "size": (self.width, self.height),
442
+ "captured_frames": self.captured_frames,
443
+ "frame_count": self.frame_count,
444
+ "active": self.active,
445
+ "error": self._error,
446
+ }
447
+
448
+ def capture(self, callback):
449
+ if self._finished or time.monotonic() < self.next_frame_at:
450
+ return
451
+
452
+ try:
453
+ image = callback._read_framebuffer()
454
+ frame = memoryview(image).tobytes()
455
+
456
+ if len(frame) != self.width * self.height * 3:
457
+ raise RuntimeError(
458
+ f"expected {self.width * self.height * 3} RGB bytes, got {len(frame)}"
459
+ )
460
+
461
+ self.process.stdin.write(frame)
462
+ self.captured_frames += 1
463
+ self.next_frame_at += 1.0 / self.fps
464
+
465
+ if self.captured_frames == self.frame_count:
466
+ self.finish()
467
+
468
+ except Exception as exc:
469
+ self.fail(exc)
470
+ traceback.print_exc()
471
+
472
+ def finish(self):
473
+ if self._finished:
474
+ return
475
+
476
+ self._finished = True
477
+ self.process.stdin.close()
478
+
479
+ if self.process.wait() != 0:
480
+ self._error = "FFmpeg encoding failed"
481
+
482
+ if self._lock_input and not self._input_was_locked:
483
+ self.controller.unlock_input()
484
+
485
+ if self._error is None:
486
+ print(
487
+ f"Saved {self.captured_frames} frames at {self.fps:g} fps to {self.filename}",
488
+ flush=True,
489
+ )
490
+
491
+ def fail(self, exc):
492
+ if self._finished:
493
+ return
494
+
495
+ self._error = str(exc)
496
+ self._finished = True
497
+ self.process.stdin.close()
498
+ self.process.terminate()
499
+ self.process.wait()
500
+
501
+ if self._lock_input and not self._input_was_locked:
502
+ self.controller.unlock_input()
503
+
504
+
505
+ class AgentInputControls:
506
+ """Model-facing ownership controls for human mouse/keyboard input."""
507
+
508
+ def __init__(self, controller):
509
+ self._controller = controller
510
+
511
+ @property
512
+ def locked(self):
513
+ return self._controller.input_locked
514
+
515
+ @locked.setter
516
+ def locked(self, value):
517
+ if not isinstance(value, bool):
518
+ raise TypeError("input.locked must be a bool")
519
+
520
+ if value:
521
+ self._controller.lock_input()
522
+
523
+ else:
524
+ self._controller.unlock_input()
525
+
526
+ def lock(self, title="LockedByAgent"):
527
+ self._controller.lock_input(title)
528
+
529
+ def unlock(self, title="Ready"):
530
+ self._controller.unlock_input(title)
531
+
532
+ def locked_input(self, locked_title="LockedByAgent", ready_title="Ready"):
533
+ return self._controller.locked_input(locked_title, ready_title)
534
+
535
+
536
+ class AgentFrameControls:
537
+ """Model-facing viewer-frame pump controls."""
538
+
539
+ def __init__(self, controller):
540
+ self._controller = controller
541
+
542
+ @property
543
+ def target_fps(self):
544
+ return self._controller.target_fps
545
+
546
+ @target_fps.setter
547
+ def target_fps(self, value):
548
+ if value is not None and (
549
+ not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0
550
+ ):
551
+ raise ValueError("frames.target_fps must be positive or None")
552
+
553
+ self._controller.target_fps = value
554
+
555
+ @property
556
+ def paused(self):
557
+ return not self._controller.running
558
+
559
+ @paused.setter
560
+ def paused(self, value):
561
+ if not isinstance(value, bool):
562
+ raise TypeError("frames.paused must be a bool")
563
+
564
+ if value:
565
+ self._controller.pause()
566
+
567
+ else:
568
+ self._controller.resume()
569
+
570
+ def step(self, count=1):
571
+ return self._controller.step(count)
572
+
573
+
574
+ class AgentWindowControls:
575
+ """Model-facing best-effort controls for the viewer's native window."""
576
+
577
+ def __init__(self, controller):
578
+ self._controller = controller
579
+ self._always_on_top = None
580
+ self._title = None
581
+
582
+ @property
583
+ def always_on_top(self):
584
+ return self._always_on_top
585
+
586
+ @always_on_top.setter
587
+ def always_on_top(self, value):
588
+ if not isinstance(value, bool):
589
+ raise TypeError("window.always_on_top must be a bool")
590
+
591
+ try:
592
+ import osgx
593
+
594
+ osgx.platform.alwaysOnTop(self._controller.viewer, value)
595
+
596
+ except Exception as exc:
597
+ raise RuntimeError("always-on-top is unavailable for this viewer") from exc
598
+
599
+ self._always_on_top = value
600
+
601
+ @property
602
+ def title(self):
603
+ return self._title
604
+
605
+ @title.setter
606
+ def title(self, value):
607
+ if not isinstance(value, str):
608
+ raise TypeError("window.title must be a str")
609
+
610
+ self._controller._set_window_title(value)
611
+ self._title = value
612
+
613
+
614
+ class AgentCaptureControls:
615
+ """Model-facing aliases for the controller's GL-safe capture requests."""
616
+
617
+ def __init__(self, controller):
618
+ self._controller = controller
619
+
620
+ def framebuffer(self, filename="frame.png", label=None):
621
+ return self._controller.capture_framebuffer(filename, label)
622
+
623
+ def framebuffer_image(self, label=None):
624
+ return self._controller.capture_framebuffer_image(label)
625
+
626
+ def texture(self, texture, filename="texture.png", data_type=GL_UNSIGNED_BYTE, label=None):
627
+ return self._controller.capture_texture(texture, filename, data_type, label)
628
+
629
+ def video(self, filename, fps=24, duration=5, lock_input=False):
630
+ return self._controller.start_video_capture(filename, fps, duration, lock_input)
631
+
632
+ @property
633
+ def video_status(self):
634
+ if self._controller._video_capture is None:
635
+ return None
636
+
637
+ return self._controller._video_capture.status
638
+
639
+
640
+ class AgentControls:
641
+ """Central, model-facing controls for a live :class:`ViewerREPLController`."""
642
+
643
+ def __init__(self, controller):
644
+ self._controller = controller
645
+ self.input = AgentInputControls(controller)
646
+ self.frames = AgentFrameControls(controller)
647
+ self.window = AgentWindowControls(controller)
648
+ self.capture = AgentCaptureControls(controller)
649
+
650
+ @property
651
+ def status(self):
652
+ return {
653
+ "input_locked": self.input.locked,
654
+ "target_fps": self.frames.target_fps,
655
+ "frames_paused": self.frames.paused,
656
+ "always_on_top": self.window.always_on_top,
657
+ "window_title": self.window.title,
658
+ "frames": self._controller.state["frames"],
659
+ "errors": self._controller.state["errors"],
660
+ "video": self.capture.video_status,
661
+ }
662
+
663
+
664
+ class ViewerREPLController(MainLoopController):
665
+ """Continuous/manual frame driver and queued same-frame capture manager."""
666
+
667
+ def __init__(self, viewer, frame_callback=None):
668
+ self.viewer = viewer
669
+ self.frame_callback = frame_callback
670
+
671
+ super().__init__(self._osg_step, lambda: viewer.done, target_fps=None)
672
+
673
+ # Keep the original diagnostic spelling available to existing examples.
674
+ self.state["frames"] = 0
675
+ self._capture_queue = []
676
+ self._video_capture = None
677
+ self._capture_callback = CaptureQueueCallback(self)
678
+ self.viewer.camera.finalDrawCallback = self._capture_callback
679
+
680
+ # insert(0, ...), not append() -- first refusal ahead of whatever handlers the caller
681
+ # already appended (e.g. pyosg-praxis.py's PraxisKeyHandler) before calling repl().
682
+ self.input_lock = AgentInputLock()
683
+ self.viewer.eventHandlers.insert(0, self.input_lock)
684
+
685
+ @property
686
+ def steps(self):
687
+ return self.state["steps"]
688
+
689
+ @property
690
+ def input_locked(self):
691
+ return self.input_lock.locked
692
+
693
+ def lock_input(self, title="LockedByAgent"):
694
+ """Start swallowing user mouse/keyboard input -- call before deterministic work
695
+ (uniform reads/writes, camera posing, queued captures) that shouldn't race the user
696
+ touching the same live window. See AgentInputLock above for exactly what's swallowed.
697
+ """
698
+
699
+ self.input_lock.locked = True
700
+ self._set_window_title(title)
701
+
702
+ def unlock_input(self, title="Ready"):
703
+ """Stop swallowing user input -- call once the deterministic work is done."""
704
+
705
+ self.input_lock.locked = False
706
+ self._set_window_title(title)
707
+
708
+ @contextlib.contextmanager
709
+ def locked_input(self, locked_title="LockedByAgent", ready_title="Ready"):
710
+ """`with controller.locked_input(): ...` -- lock, run the block, always unlock after,
711
+ even on exception. A plain (non-async) context manager is enough here: locking itself
712
+ is synchronous, and `with` around `await`-ing code inside is already valid Python, so
713
+ REPL blocks needing both (e.g. `with controller.locked_input(): await
714
+ controller.capture_framebuffer(...)`) work without an async variant.
715
+ """
716
+
717
+ self.lock_input(locked_title)
718
+
719
+ try:
720
+ yield
721
+
722
+ finally:
723
+ self.unlock_input(ready_title)
724
+
725
+ def _set_window_title(self, title):
726
+ # osgx is a sibling project (not every environment running this module has it built),
727
+ # and window retitling is cosmetic status, not core lock behavior -- so this is soft,
728
+ # best-effort: a missing osgx, a non-X11 backend, or any other failure here must never
729
+ # prevent lock_input()/unlock_input() from doing the part that actually matters.
730
+ try:
731
+ import osgx
732
+
733
+ osgx.platform.setWindowTitle(self.viewer, title)
734
+
735
+ except Exception:
736
+ pass
737
+
738
+ def _osg_step(self):
739
+ self.viewer.frame()
740
+
741
+ if self.frame_callback is not None:
742
+ self.frame_callback()
743
+
744
+ def _step_once(self, catch_keyboard_interrupt=False):
745
+ succeeded = super()._step_once(catch_keyboard_interrupt)
746
+
747
+ self.state["frames"] = self.state["steps"]
748
+
749
+ return succeeded
750
+
751
+ def capture_framebuffer(self, filename="frame.png", label=None):
752
+ request = CaptureRequest("framebuffer", filename, label)
753
+
754
+ self._capture_queue.append((request, None, GL_UNSIGNED_BYTE))
755
+
756
+ return request
757
+
758
+ def capture_framebuffer_image(self, label=None):
759
+ """Capture the next framebuffer in RAM; the result contains an ``image`` key."""
760
+ request = CaptureRequest("framebuffer", label=label, include_image=True)
761
+
762
+ self._capture_queue.append((request, None, GL_UNSIGNED_BYTE))
763
+
764
+ return request
765
+
766
+ def start_video_capture(self, filename, fps=24, duration=5, lock_input=False):
767
+ if self._video_capture is not None and self._video_capture.active:
768
+ raise RuntimeError("a video capture is already running")
769
+
770
+ self._video_capture = VideoCapture(self, filename, fps, duration, lock_input)
771
+
772
+ return self._video_capture
773
+
774
+ def capture_texture(
775
+ self,
776
+ texture,
777
+ filename="texture.png",
778
+ data_type=GL_UNSIGNED_BYTE,
779
+ label=None
780
+ ):
781
+ request = CaptureRequest("texture", filename, label)
782
+
783
+ self._capture_queue.append((request, texture, data_type))
784
+
785
+ return request
786
+
787
+ def _capture_metadata(self, request, image, data_type):
788
+ frame_stamp = self.viewer.frameStamp
789
+
790
+ return {
791
+ "kind": request.kind,
792
+ "filename": request.filename,
793
+ "label": request.label,
794
+ "frame_number": None if frame_stamp is None else frame_stamp.frameNumber,
795
+ "controller_step": self.steps + 1,
796
+ "size": (image.s, image.t),
797
+ "pixel_format": image.pixelFormat,
798
+ "data_type": image.dataType if image.dataType else data_type,
799
+ }
800
+
801
+ def repl(viewer, namespace=None, frame_callback=None):
802
+ """Drive *viewer* alongside terminal IPython or ipykernel.
803
+
804
+ `namespace` is made explicitly available so live cameras, textures, uniforms,
805
+ and other example locals remain reliable without caller-frame guessing.
806
+ `frame_callback`, when supplied, runs after each viewer.frame() for applications
807
+ that also need to drain queues or perform other per-frame Python work.
808
+
809
+ The returned ViewerREPLController supports continuous rendering, deterministic
810
+ pause/step control, and queued same-frame captures. Terminal IPython embeds a
811
+ configured asyncio-aware prompt and blocks until that prompt exits. Under
812
+ ipykernel, this registers an OSG event-loop integration and returns immediately
813
+ so Jupyter/MCP requests retain structured results.
814
+ """
815
+
816
+ if namespace is None:
817
+ namespace = {}
818
+
819
+ controller = ViewerREPLController(viewer, frame_callback)
820
+ namespace["_osg_repl_controller"] = controller
821
+ namespace["_osg_repl_state"] = controller.state
822
+ namespace["_osg_repl_controls"] = AgentControls(controller)
823
+
824
+ return drive(controller=controller, namespace=namespace)
825
+
826
+ # --------------------------------------------------------------------------- #
827
+ # Viewer setup (deliberately small; the REPL integration is the actual example)
828
+ # --------------------------------------------------------------------------- #
829
+ if __name__ == "__main__":
830
+ viewer = osgViewer.Viewer()
831
+
832
+ if len(sys.argv) > 1:
833
+ viewer.sceneData = osgDB.readNodeFile(sys.argv[1])
834
+
835
+ viewer.cameraManipulator = osgGA.TrackballManipulator()
836
+ # viewer.cameraManipulator = REPLCameraManipulator()
837
+ # viewer.cameraManipulator = CinematicOrbitManipulator(viewer.camera)
838
+ viewer.eventHandlers.append(DebugHandler())
839
+
840
+ # globals() is intentional for this proof: it makes `viewer`, `scene`, `sphere`,
841
+ # osg/osgGA/osgViewer, asyncio, and the helper itself available at the prompt.
842
+ repl(viewer, globals())