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,467 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Small, reusable pieces of the "async OSG.py" idiom -- not a framework, just the two things every
4
+ # example kept hand-rolling: (1) running viewer.frame() as an ordinary asyncio task instead of a
5
+ # bespoke synchronous pump loop, so it composes with `await` like any other coroutine, and (2)
6
+ # draining a poll()-shaped progress object (see osgx.gltf.AsyncProgress) from the coroutine that's
7
+ # already awaiting the background work, instead of routing progress through a queue and a
8
+ # call_soon_threadsafe bridge. See aipython/25-async-osgpy.md for why the poll-based half of this
9
+ # exists (a real measured 2x async/sync slowdown from the push-based alternative, caused by the
10
+ # background thread contending for the GIL with a render loop that -- under OSG_THREADING=
11
+ # SingleThreaded -- almost never voluntarily releases it).
12
+ #
13
+ # Part of the base openscenegraph wheel via examples/manifest.cmake's PYOSG_CORE_EXAMPLES, same as
14
+ # pyosg_repl.py/pyosg_visitor.py/pyosg_example.py -- stays here in examples/ rather than moving
15
+ # into python/OpenSceneGraph/, matching how those three already resolved the same question.
16
+
17
+ import asyncio
18
+
19
+ from OpenSceneGraph import *
20
+ from OpenSceneGraph.GL import *
21
+
22
+ import osgx
23
+
24
+ async def run(viewer, *coros, fps=60, max_frames=None):
25
+ """Runs viewer.frame() as an ordinary asyncio task alongside `coros`, so application code
26
+ never hand-writes its own `while not viewer.done: viewer.frame(); loop.run_until_complete(...)`
27
+ pump -- rendering is just another coroutine competing for the same event loop, the same way a
28
+ browser's requestAnimationFrame callback or Node's setInterval share their loop with everything
29
+ else.
30
+
31
+ The window closing (`viewer.done` becoming true, i.e. the user hit Escape or closed it) always
32
+ ends the session immediately, cancelling any `coros` still running -- same as it would in a
33
+ hand-written `while not viewer.done: ...` loop, where nothing after the loop runs once it
34
+ exits. Conversely, a `coros` task finishing early does NOT end the session on its own -- a
35
+ one-shot startup task (e.g. a single load-and-attach coroutine) completing must not close the
36
+ window out from under whoever's still looking at it. These two exit conditions are genuinely
37
+ asymmetric, not "wait for everything": treat `render()`'s own completion as authoritative, and
38
+ `coros` completing as informational only, unless one of them raises (which ends the session
39
+ either way, exception propagated here).
40
+
41
+ `fps` bounds how often frame() is called -- await asyncio.sleep(1 / fps) between calls, a real
42
+ sleep, so this task genuinely yields control (including, if it's a busy moment, the GIL) rather
43
+ than spinning. There is no reason to poll faster than the display can show anyway.
44
+
45
+ `max_frames`, if given, sets `viewer.done = True` after exactly that many frame() calls --
46
+ matching how the window actually closes (Escape / OS close button), NOT `viewer.close()`
47
+ (see the comment at the actual call site for why that's deliberately avoided) -- for
48
+ deterministic, scriptable runs (apitrace captures, crash repros) that don't depend on a
49
+ human pressing Escape at some approximate moment.
50
+ """
51
+
52
+ frame_interval = 1.0 / fps
53
+
54
+ async def render():
55
+ count = 0
56
+
57
+ while not viewer.done:
58
+ viewer.frame()
59
+
60
+ count += 1
61
+
62
+ if max_frames is not None and count >= max_frames:
63
+ # NOT viewer.close() -- that calls GraphicsContext::close(), which (when this
64
+ # context isn't shared) unconditionally runs osg::deleteAllGLObjects(contextID):
65
+ # a blanket "delete every GL object ever registered for this context" sweep,
66
+ # regardless of whether the owning C++ objects are still alive. A still-alive
67
+ # orphaned Camera (kept alive by another task, like Progress.watch()) has its
68
+ # real GL-side Program/buffers deleted out from under it while it still believes
69
+ # it owns them -- confirmed 2026-08-23 as the likely mechanism behind a real
70
+ # "corrupted double-linked list" abort. `done = True` matches how a window
71
+ # actually closes (Escape / OS close button) -- no explicit teardown call here.
72
+ viewer.done = True
73
+
74
+ break
75
+
76
+ await asyncio.sleep(frame_interval)
77
+
78
+ render_task = asyncio.ensure_future(render())
79
+ other_tasks = [asyncio.ensure_future(c) for c in coros]
80
+ pending = {render_task, *other_tasks}
81
+
82
+ try:
83
+ # Wait incrementally rather than for one fixed condition -- a `coros` task finishing (in
84
+ # `done`) is only checked for an exception and then dropped from `pending`; the loop keeps
85
+ # going. Only `render_task` leaving `pending` (the window actually closed) ends it, and
86
+ # only that specific check breaks the loop -- an exception from ANY task still ends things
87
+ # immediately via the raise below, on either task's completion.
88
+ while True:
89
+ done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
90
+
91
+ for task in done:
92
+ if task.cancelled():
93
+ continue
94
+
95
+ exc = task.exception()
96
+
97
+ if exc is not None:
98
+ raise exc
99
+
100
+ if render_task not in pending:
101
+ break
102
+
103
+ finally:
104
+ for task in (render_task, *other_tasks):
105
+ if not task.done():
106
+ task.cancel()
107
+
108
+ for task in (render_task, *other_tasks):
109
+ try:
110
+ await task
111
+
112
+ except (asyncio.CancelledError, Exception):
113
+ pass
114
+
115
+ async def run_with_progress(
116
+ blocking_fn,
117
+ *args,
118
+ progress=None,
119
+ on_progress=None,
120
+ stop=None,
121
+ poll_interval=1.0 / 60.0
122
+ ):
123
+ """Runs `blocking_fn(*args)` via asyncio.to_thread while polling `progress` (any object with a
124
+ no-arg poll() method returning None-or-an-update, e.g. osgx.gltf.AsyncProgress) from the
125
+ awaiting coroutine's own loop and forwarding each update to `on_progress`. Returns
126
+ blocking_fn's return value.
127
+
128
+ This is the "pull" half of the pattern: `blocking_fn` must never call back into Python itself
129
+ (no queue, no loop, no call_soon_threadsafe) -- it only needs to write into `progress` via
130
+ plain atomics, and this coroutine (which already owns the GIL as a matter of course, same as
131
+ any Python code) does the work of noticing and reacting to changes. The one exception is the
132
+ final return value, which really does cross back into Python exactly once, at completion --
133
+ that's asyncio.to_thread's own Future machinery, not something this function adds, and it was
134
+ never the source of the GIL contention this pattern exists to avoid (see the module docstring).
135
+
136
+ `poll_interval` MUST be a real, positive sleep, not 0 -- `poll()` itself being cheap (a few
137
+ atomic loads, no GIL crossing) is only free to call often when it's piggybacking on a loop
138
+ that already ticks for other reasons, like `run()`'s render loop above. This loop exists
139
+ *purely* to poll, so `await asyncio.sleep(0)` here would be a genuine unthrottled busy-loop --
140
+ CPython's zero-delay sleep is a bare cooperative yield, not a real wait, so this coroutine
141
+ would be rescheduled continuously for the entire duration of `blocking_fn`, burning ~100% of
142
+ one core on pure polling overhead. That's real OS-level CPU contention with the background
143
+ thread actually doing the work -- measured as a genuine slowdown (worse than the GIL
144
+ contention this pattern was built to remove), not a theoretical concern. Default matches a
145
+ typical 60fps render cadence; there's no reason to poll faster than progress can be displayed
146
+ anyway.
147
+
148
+ `stop` (a StopEvent), if given, is set() when this coroutine itself is cancelled, so
149
+ `blocking_fn` gets a chance to notice at its own cooperative checkpoints and return early --
150
+ cancellation here can't preempt a single opaque blocking call already in flight, same
151
+ limitation as `asyncio.CancelledError` has against any synchronous code.
152
+ """
153
+
154
+ task = asyncio.ensure_future(asyncio.to_thread(blocking_fn, *args))
155
+
156
+ try:
157
+ while not task.done():
158
+ if progress is not None and on_progress is not None:
159
+ update = progress.poll()
160
+
161
+ if update is not None:
162
+ on_progress(update)
163
+
164
+ await asyncio.sleep(poll_interval)
165
+
166
+ return await task
167
+
168
+ except asyncio.CancelledError:
169
+ if stop is not None:
170
+ stop.stop()
171
+
172
+ try:
173
+ await asyncio.shield(task)
174
+
175
+ except asyncio.CancelledError:
176
+ pass
177
+
178
+ raise
179
+
180
+ class Progress(osg.Camera):
181
+ """Base class for a screen-space progress indicator, rendered by a dedicated POST_RENDER
182
+ overlay Camera -- guaranteed to draw after (on top of) the main scene regardless of its
183
+ content, same shape as pyosg-fire.py's build_flash_camera(): identity view/projection
184
+ (children emit clip-space coordinates directly, ignoring gl_ModelViewProjectionMatrix
185
+ entirely), ABSOLUTE_RF, and clearMask=0 since this is an overlay -- clearing here would wipe
186
+ out everything the main camera already rendered this frame.
187
+
188
+ This is deliberately a real Python subclass of osg.Camera, not a plain osg.Camera returned
189
+ from a factory function with attributes bolted on afterward -- pybind11 types aren't
190
+ dynamic_attr (see feedback_avoid_dynamic_attr_use_proxy), so a genuine subclass is the only
191
+ way to get both `isinstance(x, osg.Camera)` (for attaching into a scene graph the normal way)
192
+ and Python-level state (`fraction`, `update()`) on the same object.
193
+
194
+ Subclasses provide the actual geometry/shader (a bar today; a spinner or gauge later) and
195
+ call `self._attach(geometry, program)` once, after `super().__init__()`, to wire them in.
196
+ The `uProgress`/`uFgColor`/`uBgColor` uniform contract is common to any progress
197
+ visualization and is created here, not per-subclass.
198
+ """
199
+
200
+ def __init__(
201
+ self,
202
+ width,
203
+ height,
204
+ fg_color=(0.2, 0.4, 0.2, 1.0),
205
+ bg_color=(0.15, 0.15, 0.15, 1.0),
206
+ **kwargs
207
+ ):
208
+ super().__init__(**kwargs)
209
+
210
+ self.renderOrder = osg.Camera.POST_RENDER
211
+ self.clearMask = 0
212
+ self.viewport = osg.Viewport(0, 0, width, height)
213
+ self.projectionMatrix = osg.Matrix.identity()
214
+ self.viewMatrix = osg.Matrix.identity()
215
+ self.referenceFrame = osg.Transform.ABSOLUTE_RF
216
+ self.allowEventFocus = False
217
+
218
+ ss = self.stateSet
219
+
220
+ ss.modes[GL_DEPTH_TEST] = osg.StateAttribute.OFF
221
+ ss.modes[GL_BLEND] = osg.StateAttribute.ON
222
+ ss.attributes[osg.StateAttribute.BLENDFUNC] = (
223
+ osg.BlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA), osg.StateAttribute.ON
224
+ )
225
+ ss.uniforms["uProgress"] = 0.0
226
+ ss.uniforms["uFgColor"] = osg.Vec4(*fg_color)
227
+ ss.uniforms["uBgColor"] = osg.Vec4(*bg_color)
228
+
229
+ self._fraction = 0.0
230
+
231
+ def _attach(self, geometry, program):
232
+ # self.stateSet.attributes.append(program)
233
+ # self.children.append(geometry)
234
+ geometry.stateSet.attributes.append(program)
235
+ self.children.append(geometry)
236
+
237
+ def update(self, fraction):
238
+ """Set the current progress, clamped to [0, 1]."""
239
+
240
+ self._fraction = max(0.0, min(1.0, fraction))
241
+ self.stateSet.uniforms["uProgress"] = self._fraction
242
+
243
+ @property
244
+ def fraction(self):
245
+ return self._fraction
246
+
247
+ @staticmethod
248
+ def _default_to_fraction(update):
249
+ """Interprets an osgx.gltf.AsyncProgress-shaped (stage, current, total, section, overall)
250
+ update as a 0..1 fraction -- the only progress-source shape this codebase has today. Uses
251
+ `overall` (a monotonic, whole-load estimate computed on the C++ side) rather than
252
+ re-deriving a fraction from current/total, which resets at every section boundary within
253
+ Parsing and would visibly jump backward. Pass a different `to_fraction` to watch() for any
254
+ other shape (a plain float already in [0, 1], a different tuple layout, whatever); this
255
+ default is a convenience, not a contract.
256
+ """
257
+
258
+ return update[-1]
259
+
260
+ async def watch(self, progress, poll_interval=1.0 / 60.0, to_fraction=None):
261
+ """Drives this indicator's fraction from any `.poll()`-shaped progress source (e.g.
262
+ osgx.gltf.AsyncProgress), independently of however the underlying operation is actually
263
+ run. Deliberately NOT wired through run_with_progress()'s on_progress callback -- add
264
+ this as its own coroutine to pyosg_async.run()'s task list instead:
265
+
266
+ asyncio.run(pyosg_async.run(viewer, load(...), bar.watch(progress)))
267
+
268
+ so the load and the display are fully decoupled: `load()` doesn't need to know a bar
269
+ exists (it just runs `progress` through whatever loads it), and this doesn't need to
270
+ know how `load()` runs. Anyone wanting a different display style writes their own
271
+ coroutine of this same shape instead of subclassing anything.
272
+
273
+ Runs forever, polling at `poll_interval`, until cancelled -- normally when the whole
274
+ session ends (see pyosg_async.run()'s docstring: only the window closing ends things,
275
+ so this harmlessly keeps polling a progress object that stopped changing once the load
276
+ it's watching finishes, until then).
277
+ """
278
+
279
+ to_fraction = to_fraction or self._default_to_fraction
280
+
281
+ while True:
282
+ update = progress.poll()
283
+
284
+ if update is not None:
285
+ self.update(to_fraction(update))
286
+
287
+ await asyncio.sleep(poll_interval)
288
+
289
+ @property
290
+ def fg_color(self):
291
+ return self.stateSet.uniforms["uFgColor"].value
292
+
293
+ @fg_color.setter
294
+ def fg_color(self, value):
295
+ self.stateSet.uniforms["uFgColor"] = osg.Vec4(*value)
296
+
297
+ @property
298
+ def bg_color(self):
299
+ return self.stateSet.uniforms["uBgColor"].value
300
+
301
+ @bg_color.setter
302
+ def bg_color(self, value):
303
+ self.stateSet.uniforms["uBgColor"] = osg.Vec4(*value)
304
+
305
+ BAR_VERTEX_SHADER = """
306
+ #version 430 core
307
+
308
+ uniform float uHeight = 0.05;
309
+
310
+ out float vU;
311
+
312
+ void main() {
313
+ // Same gl_VertexID-indexed quad-corner trick as pyosg-instanced.py/pyosg-fire.py, but
314
+ // pinned to the bottom of NDC space instead of centered -- a horizontal strip from
315
+ // y=-1 up to y=-1+uHeight, spanning the full width.
316
+ vec2 base[4] = vec2[4](
317
+ vec2(-1.0, -1.0),
318
+ vec2( 1.0, -1.0),
319
+ vec2( 1.0, -1.0 + uHeight),
320
+ vec2(-1.0, -1.0 + uHeight)
321
+ );
322
+
323
+ vec2 v = base[gl_VertexID % 4];
324
+
325
+ vU = (v.x + 1.0) * 0.5;
326
+
327
+ gl_Position = vec4(v, 0.0, 1.0);
328
+ }
329
+ """
330
+
331
+ BAR_FRAGMENT_SHADER = """
332
+ #version 430 core
333
+
334
+ in float vU;
335
+
336
+ uniform float uProgress = 0.0;
337
+ uniform vec4 uFgColor = vec4(0.2, 0.8, 0.3, 1.0);
338
+ uniform vec4 uBgColor = vec4(0.15, 0.15, 0.15, 1.0);
339
+
340
+ out vec4 fragColor;
341
+
342
+ void main() {
343
+ fragColor = vU <= uProgress ? uFgColor : uBgColor;
344
+ }
345
+ """
346
+
347
+ class ProgressBar(Progress):
348
+ """A left-to-right horizontal progress bar pinned to the bottom of the screen.
349
+
350
+ `bar_height` is a literal pixel thickness (default 2.5x PixelText's native glyph height --
351
+ see NATIVE_TEXT_HEIGHT below), not a window-relative divisor -- unlike the bar's own clip-
352
+ space quad shader, a percentage label needs a REAL pixel size to look right regardless of
353
+ window size (an NDC-relative font would grow/shrink with the window along with the bar), so
354
+ this class converts it to the NDC fraction (`uHeight = 2 * bar_height / height`) itself
355
+ rather than pushing that math onto every caller.
356
+ """
357
+
358
+ # PixelText's font is a fixed 5x7 grid (see osgx::PixelText::GLYPH_ROWS) -- "native" height
359
+ # here means cellSize=GLYPH_ROWS, i.e. one glyph pixel per cellSize unit, the smallest size
360
+ # at which the font is still drawn 1:1 rather than up/down-scaled.
361
+ NATIVE_TEXT_HEIGHT = osgx.PixelText.GLYPH_ROWS
362
+
363
+ def __init__(
364
+ self,
365
+ width,
366
+ height,
367
+ bar_height=2.5 * NATIVE_TEXT_HEIGHT,
368
+ show_percentage=True,
369
+ **kwargs
370
+ ):
371
+ super().__init__(width, height, **kwargs)
372
+
373
+ """
374
+ g = osg.Geode(debug=True, name="GEODE")
375
+
376
+ g.drawables.append(osg.Geometry(debug=True, name="EXPLOSION"))
377
+
378
+ self.children.append(g)
379
+ """
380
+
381
+ g = osg.Geometry()
382
+
383
+ g.primitiveSets.append(osg.DrawArrays(osg.PrimitiveSet.TRIANGLE_FAN, 0, 4))
384
+ # No real vertex data -- positions come entirely from gl_VertexID in the shader, so
385
+ # OSG has nothing to compute a bound from. Set one manually, matching the shader's own
386
+ # clip-space output range, or cull traversal (testing against this Camera's own
387
+ # identity-matrix frustum, which IS exactly the NDC cube) drops this silently.
388
+ g.initialBound = osg.BoundingBox(-1, -1, -1, 1, 1, 1)
389
+
390
+ p = osg.Program(name="pyosg_async.ProgressBar", shaders=(
391
+ osg.Shader(osg.Shader.VERTEX, BAR_VERTEX_SHADER),
392
+ osg.Shader(osg.Shader.FRAGMENT, BAR_FRAGMENT_SHADER)
393
+ ))
394
+
395
+ self._attach(g, p)
396
+
397
+ self._width = width
398
+ self._height = height
399
+ self._bar_pixel_height = bar_height
400
+ self.stateSet.uniforms["uHeight"] = 2.0 * bar_height / height
401
+
402
+ self._label = None
403
+
404
+ if show_percentage:
405
+ self._build_label()
406
+
407
+ def _build_label(self):
408
+ """Adds a "NN%" osgx.PixelText label, white-inked, vertically centered inside the bar
409
+ itself -- as a plain child of THIS Camera (same POST_RENDER subgraph as the bar quad),
410
+ not a second nested Camera. PixelText's vertex shader positions glyphs via the standard
411
+ osg_ModelViewProjectionMatrix; since this Camera's view/projection are both identity
412
+ (see Progress.__init__), that MVP is just whatever Model matrix sits above the label in
413
+ the scene graph -- so `self._label_transform` below IS this label's only "projection",
414
+ reproducing an osg.Matrix.ortho2D(0, width, 0, height) camera (the convention every
415
+ other PixelText call site uses, e.g. pyosg-dice.py's HUD label) as one plain matrix
416
+ instead of a second Camera. See _position_label() for the actual math.
417
+ """
418
+
419
+ self._label = osgx.PixelText("0%", 1.0 * self.NATIVE_TEXT_HEIGHT)
420
+ self._label.ink = osg.Vec4(1.0, 1.0, 1.0, 1.0)
421
+
422
+ geode = osg.Geode(name="progress-label")
423
+
424
+ geode.drawables.append(self._label)
425
+
426
+ self._label_transform = osg.MatrixTransform()
427
+
428
+ self._label_transform.children.append(geode)
429
+
430
+ self.children.append(self._label_transform)
431
+
432
+ self._position_label()
433
+
434
+ def _position_label(self):
435
+ """Re-centers the label (both axes) over the bar. Called on every update() since the
436
+ text's width changes as the digit count changes ("0%" vs "100%") -- width is derived
437
+ from cellSize * len(text) rather than PixelText's own bounding box, since advance
438
+ defaults to cellSize (the monospace case) and this avoids depending on a bounding-box
439
+ query that may not be exposed to Python.
440
+
441
+ The label's matrix does two jobs at once, applied in this order (OSG's row-vector
442
+ convention: `v * A * B` applies A first, so the written order below IS the applied
443
+ order): position the label first, in the same pixel units PixelText's cellSize is
444
+ already in, then convert THAT ENTIRE pixel-space placement to clip space in one step --
445
+ exactly what osg.Matrix.ortho2D(0, width, 0, height) does as a projection matrix, just
446
+ folded into this one model matrix instead, so it composes correctly under this Camera's
447
+ identity projection without needing a second Camera to hold a real one.
448
+ """
449
+
450
+ cell_size = self._label.cellSize
451
+ text_width = cell_size * len(self._label.text)
452
+ x = (self._width - text_width) / 2.0
453
+ y = (self._bar_pixel_height - cell_size) / 2.0
454
+
455
+ self._label_transform.matrix = (
456
+ osg.Matrix.translate(x, y, 0.0) *
457
+ osg.Matrix.scale(2.0 / self._width, 2.0 / self._height, 1.0) *
458
+ osg.Matrix.translate(-1.0, -1.0, 0.0)
459
+ )
460
+
461
+ def update(self, fraction):
462
+ super().update(fraction)
463
+
464
+ if self._label is not None:
465
+ self._label.text = f"{round(self._fraction * 100)}%"
466
+
467
+ self._position_label()
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import os
4
+
5
+ # setdefault(), not update() -- same reason as pyosg_visitor.py: an example that already set its
6
+ # own OSG_WINDOW/OSG_THREADING/etc. (window size, a non-default GL version, ...) before importing
7
+ # this keeps what it set; this only fills in whatever it didn't. Importing this is now the ONE
8
+ # place "SingleThreaded is mandatory" (see feedback_viewer_close_threading_deadlock -- a live
9
+ # non-SingleThreaded draw thread can deadlock a Viewer's destructor) is declared -- an example
10
+ # that imports this for window_size() gets it whether or not its own env block remembers to say
11
+ # so, instead of a standing policy that only holds as well as 42 separate copy-pasted blocks do.
12
+ os.environ.setdefault("OSG_WINDOW", "50 50 800 600")
13
+ os.environ.setdefault("OSG_THREADING", "SingleThreaded")
14
+ os.environ.setdefault("OSG_GL_CONTEXT_PROFILE_MASK", "1")
15
+ os.environ.setdefault("OSG_GL_VERSION", "4.6")
16
+ os.environ.setdefault("OSG_GL_CONTEXT_VERSION", "4.6")
17
+
18
+ # Derives (width, height) from OSG_WINDOW ("x y width height", e.g. "50 50 800 600") instead of a
19
+ # second, separately-hardcoded W, H module constant -- one declared value per file instead of two
20
+ # that can silently drift apart. Standalone __main__ blocks call this; the pyosg/
21
+ # OpenSceneGraph.examples runners never do (they pass their own --width/--height straight into
22
+ # build_scene() and viewer.setUpViewInWindow()). See build_scene()'s own contract comment in
23
+ # pyosg-mrt.py: (w, h) stays an explicit argument on every build_scene(), never something it reads
24
+ # from the environment itself -- this function is the caller-side counterpart of that rule, not an
25
+ # exception to it.
26
+ def window_size(default=(800, 600)):
27
+ spec = os.environ.get("OSG_WINDOW")
28
+
29
+ if not spec:
30
+ return default
31
+
32
+ x, y, w, h = spec.split()
33
+
34
+ return int(w), int(h)
35
+
36
+ # Deliberately here, not at module top -- osg must never be imported before the
37
+ # os.environ.setdefault() block above runs (see that block's own comment), and this module gets
38
+ # imported by every example specifically so those defaults land before ITS OWN internal osg
39
+ # import, let alone the caller's later `from OpenSceneGraph import *`.
40
+ from OpenSceneGraph import osg
41
+ from OpenSceneGraph.GL import GL_DEPTH_TEST
42
+ import osgx
43
+
44
+ # Same technique as pyosg_async.py's ProgressBar._build_label()/_position_label() -- a plain
45
+ # child of an identity-view/projection POST_RENDER Camera, hand-composed translate*scale*translate
46
+ # matrix folding pixel-space placement directly into clip space (osg's row-vector convention, so
47
+ # written left-to-right IS the applied order: position in pixel space first, then map that whole
48
+ # placement into NDC in one step) instead of a second Camera holding a real ortho2D projection.
49
+ # Fixed to the (w, h) passed in, same limitation ProgressBar's label has: build_scene(w, h) has no
50
+ # live viewer to read a real/resized window size from, so this does not track a later resize --
51
+ # pass the SAME (w, h) build_scene() itself received.
52
+ def label(text, w, h, corner="bottom-left", scale=2.0, margin=12.0, ink=(1.0, 1.0, 1.0, 1.0)):
53
+ """A small screen-space HUD text overlay any example can drop into its scene, e.g. for
54
+ "Press R to reroll"-style on-screen hints. Returns a ready-to-attach osg.Camera -- add it as a
55
+ child anywhere in build_scene()'s returned graph:
56
+
57
+ root.children.append(pyosg_example.label("Press R to reroll", w, h))
58
+
59
+ `scale` is a multiple of osgx.PixelText's native glyph size (GLYPH_ROWS tall, GLYPH_COLS
60
+ wide); the default of 2.0 draws at 2x native resolution, per the user's own request.
61
+ `corner` is one of "bottom-left" (default), "bottom-right", "top-left", "top-right".
62
+ `margin` is a literal pixel gap from the chosen corner's two edges.
63
+ """
64
+
65
+ cell_size = osgx.PixelText.GLYPH_ROWS * scale
66
+ pixel_text = osgx.PixelText(text, cell_size)
67
+
68
+ # PixelText's own cellSize (and its default advance) is a SQUARE per-character cell sized
69
+ # off GLYPH_ROWS (see osgx::PixelText::createAtlas()'s own comment: "a glyph block is only
70
+ # GLYPH_COLS * pixelScale wide but GLYPH_ROWS * pixelScale tall" -- the glyph itself sits
71
+ # centered in that square with margin on the narrower axis). Leaving advance at its default
72
+ # spaces characters a full cellSize apart -- visibly wider than the glyph's own native
73
+ # GLYPH_COLS-wide footprint. Tightening it to the glyph's real width gives ordinary,
74
+ # non-monospace-square-cell text spacing instead.
75
+ advance = osgx.PixelText.GLYPH_COLS * scale
76
+
77
+ pixel_text.advance = advance
78
+ pixel_text.ink = osg.Vec4(*ink)
79
+
80
+ geode = osg.Geode(name="hud-label")
81
+
82
+ geode.drawables.append(pixel_text)
83
+
84
+ # Matches PixelText's own internal width formula (see PixelText.cpp's computeBoundingBox()):
85
+ # every glyph but the last only takes `advance` of horizontal room; the last one still needs
86
+ # its own full cellSize, since nothing after it truncates its trailing margin.
87
+ text_width = max(len(text) - 1, 0) * advance + cell_size
88
+ corners = {
89
+ "bottom-left": (margin, margin),
90
+ "bottom-right": (w - margin - text_width, margin),
91
+ "top-left": (margin, h - margin - cell_size),
92
+ "top-right": (w - margin - text_width, h - margin - cell_size),
93
+ }
94
+
95
+ if corner not in corners:
96
+ raise ValueError(f"label: unknown corner {corner!r} (expected one of {sorted(corners)})")
97
+
98
+ x, y = corners[corner]
99
+ transform = osg.MatrixTransform()
100
+
101
+ transform.matrix = (
102
+ osg.Matrix.translate(x, y, 0.0) *
103
+ osg.Matrix.scale(2.0 / w, 2.0 / h, 1.0) *
104
+ osg.Matrix.translate(-1.0, -1.0, 0.0)
105
+ )
106
+ transform.children.append(geode)
107
+
108
+ camera = osg.Camera(name="hud-label-camera")
109
+
110
+ camera.renderOrder = osg.Camera.POST_RENDER
111
+ camera.clearMask = 0
112
+ camera.referenceFrame = osg.Transform.ABSOLUTE_RF
113
+ camera.viewport = osg.Viewport(0, 0, w, h)
114
+ camera.viewMatrix = osg.Matrix.identity()
115
+ camera.projectionMatrix = osg.Matrix.identity()
116
+ camera.allowEventFocus = False
117
+ camera.stateSet.modes[GL_DEPTH_TEST] = osg.StateAttribute.OFF
118
+ camera.children.append(transform)
119
+
120
+ return camera