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.
- OpenSceneGraph/OpenThreads.lib +0 -0
- OpenSceneGraph/_OpenSceneGraph.cp313-win_amd64.pyd +0 -0
- OpenSceneGraph/__init__.py +52 -0
- OpenSceneGraph/aipython/00-index.md +44 -0
- OpenSceneGraph/aipython/01-core.md +255 -0
- OpenSceneGraph/aipython/02-inspect.md +81 -0
- OpenSceneGraph/aipython/03-headless-frames.md +149 -0
- OpenSceneGraph/aipython/05-camera-manipulator.md +68 -0
- OpenSceneGraph/aipython/06-camera-effects.md +132 -0
- OpenSceneGraph/aipython/07-camera-manual.md +88 -0
- OpenSceneGraph/aipython/08-lighting.md +121 -0
- OpenSceneGraph/aipython/09-picking.md +90 -0
- OpenSceneGraph/aipython/10-rtt.md +199 -0
- OpenSceneGraph/aipython/11-mrt.md +104 -0
- OpenSceneGraph/aipython/12-gbuffer.md +48 -0
- OpenSceneGraph/aipython/15-shader-hotswap.md +72 -0
- OpenSceneGraph/aipython/17-particles.md +189 -0
- OpenSceneGraph/aipython/18-deterministic-captures.md +157 -0
- OpenSceneGraph/aipython/20-object-lifetime.md +77 -0
- OpenSceneGraph/aipython/25-async-osgpy.md +228 -0
- OpenSceneGraph/aipython/29-material.md +109 -0
- OpenSceneGraph/aipython/30-pbribl.md +162 -0
- OpenSceneGraph/aipython/40-typed-lights-gizmos.md +218 -0
- OpenSceneGraph/examples/__init__.py +24 -0
- OpenSceneGraph/examples/__main__.py +143 -0
- OpenSceneGraph/examples/blur.py +370 -0
- OpenSceneGraph/examples/info.py +126 -0
- OpenSceneGraph/examples/mrt.py +493 -0
- OpenSceneGraph/examples/pyosg_async.py +467 -0
- OpenSceneGraph/examples/pyosg_example.py +120 -0
- OpenSceneGraph/examples/pyosg_repl.py +842 -0
- OpenSceneGraph/examples/pyosg_visitor.py +138 -0
- OpenSceneGraph/ktx.dll +0 -0
- OpenSceneGraph/ktx.lib +0 -0
- OpenSceneGraph/osg.lib +0 -0
- OpenSceneGraph/osg161-osg.dll +0 -0
- OpenSceneGraph/osg161-osgAnimation.dll +0 -0
- OpenSceneGraph/osg161-osgDB.dll +0 -0
- OpenSceneGraph/osg161-osgFX.dll +0 -0
- OpenSceneGraph/osg161-osgGA.dll +0 -0
- OpenSceneGraph/osg161-osgText.dll +0 -0
- OpenSceneGraph/osg161-osgUtil.dll +0 -0
- OpenSceneGraph/osg161-osgViewer.dll +0 -0
- OpenSceneGraph/osg161-osgWidget.dll +0 -0
- OpenSceneGraph/osgAnimation.lib +0 -0
- OpenSceneGraph/osgDB.lib +0 -0
- OpenSceneGraph/osgFX.lib +0 -0
- OpenSceneGraph/osgGA.lib +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/jpeg62-ebb2f26be87097e77bafd1e9095b4820.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/ktx.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/liblzma-b9cff3753c4848b9ff1350ba5a053c6e.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/libpng16-36b7e1e8d185cb21280e5688f99660c0.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/msvcp140.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_bmp.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_dds.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_gltf.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_hdr.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_jpeg.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_ktx2.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_obj.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_osg.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_png.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_pnm.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_rgb.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_serializers_osg.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_stl.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_tga.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/osgdb_tiff.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/tiff-1defa8059e5ba115a3ab4b120de6f4bb.dll +0 -0
- OpenSceneGraph/osgPlugins-3.6.5/z.dll +0 -0
- OpenSceneGraph/osgText.lib +0 -0
- OpenSceneGraph/osgUtil.lib +0 -0
- OpenSceneGraph/osgViewer.lib +0 -0
- OpenSceneGraph/osgWidget.lib +0 -0
- OpenSceneGraph/osgx.cp313-win_amd64.pyd +0 -0
- OpenSceneGraph/osgx_static.lib +0 -0
- OpenSceneGraph/ot21-OpenThreads.dll +0 -0
- openscenegraph-0.1.2.dist-info/DELVEWHEEL +2 -0
- openscenegraph-0.1.2.dist-info/METADATA +563 -0
- openscenegraph-0.1.2.dist-info/RECORD +90 -0
- openscenegraph-0.1.2.dist-info/WHEEL +5 -0
- openscenegraph-0.1.2.dist-info/entry_points.txt +6 -0
- openscenegraph-0.1.2.dist-info/licenses/LICENSE +21 -0
- openscenegraph.libs/jpeg62-ebb2f26be87097e77bafd1e9095b4820.dll +0 -0
- openscenegraph.libs/liblzma-b9cff3753c4848b9ff1350ba5a053c6e.dll +0 -0
- openscenegraph.libs/libpng16-36b7e1e8d185cb21280e5688f99660c0.dll +0 -0
- openscenegraph.libs/msvcp140.dll +0 -0
- openscenegraph.libs/tiff-1defa8059e5ba115a3ab4b120de6f4bb.dll +0 -0
- openscenegraph.libs/z.dll +0 -0
- 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())
|