OpenSceneGraph 0.1.1__cp312-cp312-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 (87) hide show
  1. OpenSceneGraph/OpenThreads.lib +0 -0
  2. OpenSceneGraph/_OpenSceneGraph.cp312-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 +133 -0
  26. OpenSceneGraph/examples/blur.py +370 -0
  27. OpenSceneGraph/examples/info.py +126 -0
  28. OpenSceneGraph/examples/mrt.py +491 -0
  29. OpenSceneGraph/examples/pyosg_visitor.py +138 -0
  30. OpenSceneGraph/ktx.dll +0 -0
  31. OpenSceneGraph/ktx.lib +0 -0
  32. OpenSceneGraph/osg.lib +0 -0
  33. OpenSceneGraph/osg161-osg.dll +0 -0
  34. OpenSceneGraph/osg161-osgAnimation.dll +0 -0
  35. OpenSceneGraph/osg161-osgDB.dll +0 -0
  36. OpenSceneGraph/osg161-osgFX.dll +0 -0
  37. OpenSceneGraph/osg161-osgGA.dll +0 -0
  38. OpenSceneGraph/osg161-osgText.dll +0 -0
  39. OpenSceneGraph/osg161-osgUtil.dll +0 -0
  40. OpenSceneGraph/osg161-osgViewer.dll +0 -0
  41. OpenSceneGraph/osg161-osgWidget.dll +0 -0
  42. OpenSceneGraph/osgAnimation.lib +0 -0
  43. OpenSceneGraph/osgDB.lib +0 -0
  44. OpenSceneGraph/osgFX.lib +0 -0
  45. OpenSceneGraph/osgGA.lib +0 -0
  46. OpenSceneGraph/osgPlugins-3.6.5/jpeg62-0cd23f436ca2f903930938a97613737c.dll +0 -0
  47. OpenSceneGraph/osgPlugins-3.6.5/ktx.dll +0 -0
  48. OpenSceneGraph/osgPlugins-3.6.5/liblzma-112dab4a4491f49f4a39110c31cbeb93.dll +0 -0
  49. OpenSceneGraph/osgPlugins-3.6.5/libpng16-68c6d401ebba995894fabb1f17f527e9.dll +0 -0
  50. OpenSceneGraph/osgPlugins-3.6.5/msvcp140.dll +0 -0
  51. OpenSceneGraph/osgPlugins-3.6.5/osgdb_bmp.dll +0 -0
  52. OpenSceneGraph/osgPlugins-3.6.5/osgdb_dds.dll +0 -0
  53. OpenSceneGraph/osgPlugins-3.6.5/osgdb_gltf.dll +0 -0
  54. OpenSceneGraph/osgPlugins-3.6.5/osgdb_hdr.dll +0 -0
  55. OpenSceneGraph/osgPlugins-3.6.5/osgdb_jpeg.dll +0 -0
  56. OpenSceneGraph/osgPlugins-3.6.5/osgdb_ktx2.dll +0 -0
  57. OpenSceneGraph/osgPlugins-3.6.5/osgdb_obj.dll +0 -0
  58. OpenSceneGraph/osgPlugins-3.6.5/osgdb_osg.dll +0 -0
  59. OpenSceneGraph/osgPlugins-3.6.5/osgdb_png.dll +0 -0
  60. OpenSceneGraph/osgPlugins-3.6.5/osgdb_pnm.dll +0 -0
  61. OpenSceneGraph/osgPlugins-3.6.5/osgdb_rgb.dll +0 -0
  62. OpenSceneGraph/osgPlugins-3.6.5/osgdb_serializers_osg.dll +0 -0
  63. OpenSceneGraph/osgPlugins-3.6.5/osgdb_stl.dll +0 -0
  64. OpenSceneGraph/osgPlugins-3.6.5/osgdb_tga.dll +0 -0
  65. OpenSceneGraph/osgPlugins-3.6.5/osgdb_tiff.dll +0 -0
  66. OpenSceneGraph/osgPlugins-3.6.5/tiff-d69a6c92f7f356ee0ae62ceaa27d440f.dll +0 -0
  67. OpenSceneGraph/osgPlugins-3.6.5/z.dll +0 -0
  68. OpenSceneGraph/osgText.lib +0 -0
  69. OpenSceneGraph/osgUtil.lib +0 -0
  70. OpenSceneGraph/osgViewer.lib +0 -0
  71. OpenSceneGraph/osgWidget.lib +0 -0
  72. OpenSceneGraph/osgx.cp312-win_amd64.pyd +0 -0
  73. OpenSceneGraph/osgx_static.lib +0 -0
  74. OpenSceneGraph/ot21-OpenThreads.dll +0 -0
  75. openscenegraph-0.1.1.dist-info/DELVEWHEEL +2 -0
  76. openscenegraph-0.1.1.dist-info/METADATA +563 -0
  77. openscenegraph-0.1.1.dist-info/RECORD +87 -0
  78. openscenegraph-0.1.1.dist-info/WHEEL +5 -0
  79. openscenegraph-0.1.1.dist-info/entry_points.txt +6 -0
  80. openscenegraph-0.1.1.dist-info/licenses/LICENSE +21 -0
  81. openscenegraph.libs/jpeg62-0cd23f436ca2f903930938a97613737c.dll +0 -0
  82. openscenegraph.libs/liblzma-112dab4a4491f49f4a39110c31cbeb93.dll +0 -0
  83. openscenegraph.libs/libpng16-68c6d401ebba995894fabb1f17f527e9.dll +0 -0
  84. openscenegraph.libs/msvcp140.dll +0 -0
  85. openscenegraph.libs/tiff-d69a6c92f7f356ee0ae62ceaa27d440f.dll +0 -0
  86. openscenegraph.libs/z.dll +0 -0
  87. osgx.py +7 -0
Binary file
@@ -0,0 +1,52 @@
1
+ """Public Python package for the OpenSceneGraph native bindings.
2
+
3
+ The compiled implementation remains private so this package can grow Python
4
+ helpers, utilities, and examples without changing the public import surface.
5
+ """
6
+
7
+
8
+ # start delvewheel patch
9
+ def _delvewheel_patch_1_13_1():
10
+ import os
11
+ if os.path.isdir(libs_dir := os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, 'openscenegraph.libs'))):
12
+ os.add_dll_directory(libs_dir)
13
+
14
+
15
+ _delvewheel_patch_1_13_1()
16
+ del _delvewheel_patch_1_13_1
17
+ # end delvewheel patch
18
+
19
+ import os as _os
20
+ from pathlib import Path as _Path
21
+
22
+ # The core extension's directory contains the project-built OSG DLLs and ktx.
23
+ # osgDB loads format plugins from its child directory later; on Windows those
24
+ # plugins need their parent DLL directory in the process search path too.
25
+ _dll_directory = None
26
+ _plugin_dll_directory = None
27
+ _package_directory = _Path(__file__).parent
28
+ _plugin_directory = _package_directory / "osgPlugins-3.6.5"
29
+ if _os.name == "nt":
30
+ # osgDB loads plugins through legacy LoadLibrary(), which does not honor
31
+ # Python's add_dll_directory() entries. Keep both directories on PATH for
32
+ # their transitive DLL dependencies as well.
33
+ _os.environ["PATH"] = _os.pathsep.join(
34
+ (str(_package_directory), str(_plugin_directory), _os.environ.get("PATH", ""))
35
+ )
36
+ if hasattr(_os, "add_dll_directory"):
37
+ _dll_directory = _os.add_dll_directory(str(_package_directory))
38
+ _plugin_dll_directory = _os.add_dll_directory(str(_plugin_directory))
39
+
40
+ from ._OpenSceneGraph import * # noqa: F401,F403
41
+ from . import _OpenSceneGraph as _native
42
+
43
+ import sys as _sys
44
+
45
+ # The native extension creates these submodules. Publish them under their
46
+ # long-standing public paths, so both old and package-style imports work.
47
+ for _name in ("osg", "osgAnimation", "osgUtil", "osgDB", "osgGA", "osgViewer", "GL"):
48
+ _sys.modules[f"{__name__}.{_name}"] = getattr(_native, _name)
49
+
50
+ __all__ = [name for name in dir(_native) if not name.startswith("_")]
51
+
52
+ del _name, _native, _sys, _os, _Path
@@ -0,0 +1,44 @@
1
+ # aipython + OpenSceneGraph.py — Index
2
+
3
+ This directory is OpenSceneGraph.py's accumulated knowledge for driving a
4
+ live OSG viewer through the `aipython` MCP bridge (tmux or Jupyter-kernel
5
+ backed IPython). It exists because this project's REPL setup has sharp,
6
+ non-obvious edges that get rediscovered by trial and error unless written
7
+ down.
8
+
9
+ **Always read [`01-core.md`](01-core.md) first**, in any session that's
10
+ about to drive `viewer.frame()` live via `aipython`, before writing any
11
+ callback or running any multi-line block through a tmux-backed session.
12
+
13
+ Read the others situationally:
14
+
15
+ | File | Read when... |
16
+ |---|---|
17
+ | [`01-core.md`](01-core.md) | Always, first. Session bootstrap, callback safety, tmux/IPython mechanics, and the Pythonic-vs-raw-OSG binding surface. |
18
+ | [`02-inspect.md`](02-inspect.md) | Handed a live viewer or loaded scene you didn't build — get a real read on its structure/shaders/uniforms instead of guessing. |
19
+ | [`03-headless-frames.md`](03-headless-frames.md) | Testing callbacks, events, visitors, or Python/C++ trampolines without behavior that genuinely needs a Viewer, graphics context, cull, or draw traversal. |
20
+ | [`05-camera-manipulator.md`](05-camera-manipulator.md) | Building/customizing an `osgGA.CameraManipulator` subclass, or doing camera-relative work (e.g. "light the subject from the camera's upper-right"). |
21
+ | [`06-camera-effects.md`](06-camera-effects.md) | Layering a TEMPORARY camera effect (shake, kick, scripted move) on top of the user's live manipulator without taking control away. |
22
+ | [`07-camera-manual.md`](07-camera-manual.md) | Driving `viewer.camera` directly with NO manipulator (a fixed/orthographic camera) — `realize()`-before-matrices ordering, near/far, and viewport-confinement gotchas. |
23
+ | [`08-lighting.md`](08-lighting.md) | Getting PBR/IBL lighting going via `osgx`'s `#pragma osgx::*` shader-library system — fastest path to a lit glTF model, plus the full IBL pipeline. |
24
+ | [`09-picking.md`](09-picking.md) | Wiring `osgx` (hover/click) into a scene that ALSO has an `osgx.imgui.Widget` panel — three independent guards needed to block picking near/under ImGui. |
25
+ | [`10-rtt.md`](10-rtt.md) | Building a render-to-texture / multi-camera scene graph live. **Read before debugging any fullscreen pass that outputs one flat color** — fullscreen quads must disable `GL_DEPTH_TEST` or they break silently once re-targeted to an FBO. |
26
+ | [`11-mrt.md`](11-mrt.md) | Reading/writing multiple render targets, linearizing RTT depth with the actual effective camera projection, or combining pass callbacks with `osgx.imgui.Widget`. |
27
+ | [`12-gbuffer.md`](12-gbuffer.md) | Designing a generic G-buffer or composing deferred/NPR post-process passes from canonical color, normal, and depth data. |
28
+ | [`15-shader-hotswap.md`](15-shader-hotswap.md) | Debugging shader-side logic live by patching GLSL and hot-swapping a `Program`, or when a live variable reassignment "has no effect." |
29
+ | [`17-particles.md`](17-particles.md) | Building a one-shot, GPU-only instanced particle/burst effect (fire, explosions, debris) driven by `osg_SimulationTime` + a `triggerTime` uniform, or wiring an `osgx.imgui` live-tuning panel on top of one. |
30
+ | [`18-deterministic-captures.md`](18-deterministic-captures.md) | Capturing a precise, repeatable animation state by freezing an effect-local elapsed-time uniform instead of racing the realtime frame loop. |
31
+ | [`20-object-lifetime.md`](20-object-lifetime.md) | Investigating a leak, a "removed but still alive" object, or verifying true C++ destruction vs. just scene-graph detachment. |
32
+ | [`25-async-osgpy.md`](25-async-osgpy.md) | Any background OSG.py operation (not just glTF loading): push vs. poll, why a naive async loader can be *slower* than sync (GIL contention between the render pump and a push-based progress mechanism), making `viewer.frame()` an ordinary `asyncio` task, and where the ceiling is (GIL, non-preemptible `frame()`, cooperative cancellation). Read before assuming async is "free" overlap with rendering. |
33
+ | [`29-material.md`](29-material.md) | Setting a PBR material (base color/roughness/metallic/maps) on any Drawable via `osgx.Material` — a real `osg.StateAttribute`. Read before `30-pbribl.md` if you just need factors on a shape, no full IBL renderer. |
34
+ | [`30-pbribl.md`](30-pbribl.md) | Applying `osgx.gltf.pbribl`'s full reflective PBR/IBL renderer to ordinary OSG geometry such as `ShapeDrawable`. |
35
+ | [`40-typed-lights-gizmos.md`](40-typed-lights-gizmos.md) | Adding typed direct/punctual lights (`osgx.LightSet` — Point/Directional/Spot/Sphere) and their debug gizmos (`osgx.LightGizmos`/`osgx.LightMarkers`) to a live scene; also documents the `osgx_DirectLighting()` hook contract every direct-lit shader should call into. |
36
+
37
+ ## Why this exists as `aipython/*.md` and not a Claude Code skill
38
+
39
+ Deliberately a plain, agent-agnostic directory of markdown files at the repo
40
+ root, not `.claude/skills/`. Multiple agentic tools (Claude Code, Codex,
41
+ others) use this project via the `aipython` MCP server; a Claude-Code-specific
42
+ location would hide this from the rest. An agent working in this repo should
43
+ proactively `ls`/`Read` this directory before starting REPL work here, the
44
+ same way it would check `CLAUDE.md`.
@@ -0,0 +1,255 @@
1
+ # Core rules for any OSG.py + aipython REPL session
2
+
3
+ Read this before driving `viewer.frame()` live, writing any callback, or sending
4
+ multi-line code through a tmux-backed session.
5
+
6
+ ## 1. Launch `pyosg_repl.py` directly — never from inside an already-running `ipython3`
7
+
8
+ `repl(viewer, namespace)` embeds its own IPython shell (`InteractiveShellEmbed`).
9
+ Launching a bare `ipython3` first and calling `repl()` from inside it raises:
10
+
11
+ ```
12
+ MultipleInstanceError: An incompatible sibling of 'InteractiveShellEmbed' is
13
+ already instanciated as singleton: TerminalInteractiveShell
14
+ ```
15
+
16
+ Run the script directly (`python3 pyosg_repl.py`) so `repl()` is the first shell
17
+ created. Edit the `if __name__ == "__main__":` block for a custom scene instead
18
+ of driving a pre-existing prompt.
19
+
20
+ ## 2. Bind free variables into callbacks as default args
21
+
22
+ C++-invoked callbacks (draw callbacks, `osgx.imgui` sections, `debug=` deletion
23
+ callbacks, `NodeCallback`/`updateCallback`, event handlers) can fail to resolve
24
+ names defined at the prompt after the shell embedded — `user_ns` and
25
+ `user_module.__dict__` can be separate dicts. The terminal backend merges them
26
+ at embed time, but defend anyway:
27
+
28
+ ```python
29
+ spin_xform.updateCallback = lambda node, nv, osg=osg: setattr(
30
+ node, "matrix", osg.Matrix.rotate(nv.frameStamp.simulationTime * 0.4, osg.Vec3(0, 0, 1))
31
+ )
32
+ ```
33
+
34
+ Consequences differ by callback type:
35
+ - A `DrawCallback` exception crashes the render thread/process.
36
+ - An `osgx.imgui` section exception corrupts ImGui's frame state; the *next*
37
+ frame hard-aborts the process — no `try/except` catches this after the fact.
38
+ - A `NodeCallback`/`updateCallback` exception doesn't crash but silently
39
+ breaks the update traversal every frame — queued captures stop resolving,
40
+ input stops responding. Check `_osg_repl_state` and try a plain
41
+ `viewer.frame()` to surface the real traceback.
42
+ - A `debug=<callable>` deletion callback (see [`20-object-lifetime.md`](20-object-lifetime.md))
43
+ hits the same error from an ordinary GC destructor call, main thread, no
44
+ render loop involved.
45
+
46
+ If wrapping defensively: set a one-shot "done" flag before risky code, wrap
47
+ the body in `try/except Exception:`, write results to a file rather than a
48
+ shared Python object — `except: pass` makes failures silent, not absent.
49
+
50
+ ## 3. `os.environ.setdefault()`, never `.update()`, in shared helper modules
51
+
52
+ An unconditional `os.environ.update(...)` in a helper (e.g. `pyosg_repl.py`)
53
+ clobbers a caller's already-set `OSG_WINDOW`/`OSG_THREADING`/etc. back to the
54
+ helper's defaults. `Viewer::realize()` doesn't read `OSG_WINDOW` until the
55
+ first `frame()` call, so this can look like an unrelated timing/display bug
56
+ well after import. Use `setdefault()`.
57
+
58
+ ## 4. Multi-line code sent through tmux is fragile
59
+
60
+ IPython's terminal autoindent stacks on top of pasted indentation and can
61
+ cascade into `IndentationError`, or silently corrupt a triple-quoted string.
62
+
63
+ - Send `%autoindent off` as the first command in any tmux session, once.
64
+ - Prefer single-line forms (lambdas, `exec("...")`) for anything defined live.
65
+ - After a large block, call `capture()` again explicitly rather than trusting
66
+ `execute()`'s returned text, which can be a stale snapshot.
67
+
68
+ Most reliable way to run a nontrivial script live — two single-line
69
+ statements, neither has an indented continuation:
70
+
71
+ ```python
72
+ p = "/absolute/path/to/script.py"
73
+ exec(compile(open(p).read(), p, "exec"), locals())
74
+ ```
75
+
76
+ Two requirements:
77
+ - Use the real absolute path as the `compile()` filename — a fake name makes
78
+ `stack_data`/`executing` throw and replaces the real traceback with a wrong
79
+ one pointing at unrelated code.
80
+ - Pass `locals()` explicitly. `globals() is locals()` is `False` at this
81
+ shell's top-level prompt (top-level-await wraps each cell in an async
82
+ function), so a bare `exec(code)` binds any `def`'s `__globals__` to a dict
83
+ missing `osg`/`viewer`/etc., raising `NameError` only when that function is
84
+ later called from elsewhere.
85
+
86
+ ## 5. `help(x)` hangs the session
87
+
88
+ Opens a pager; the tmux pane blocks waiting for input (`idle: false` forever).
89
+ Send `"q"` to unstick it, confirm recovery with a throwaway `execute()`.
90
+ Prefer `x.__doc__` instead.
91
+
92
+ ## 6. Screenshots: use the controller's queued capture, never bare `readPixels()`
93
+
94
+ ```python
95
+ # BROKEN — the GL context isn't necessarily current here; silently returns
96
+ # all-zero (black) data, no exception:
97
+ img = osg.Image()
98
+ img.readPixels(0, 0, w, h, GL_RGB, GL_UNSIGNED_BYTE)
99
+
100
+ # WORKS — queued into the render loop's own finalDrawCallback, context
101
+ # guaranteed current:
102
+ result = await _osg_repl_controller.capture_framebuffer("/tmp/shot.png")
103
+ ```
104
+
105
+ If a screenshot comes back black, rule out the capture path first (check the
106
+ window directly, check `_osg_repl_state["frames"]` is incrementing) before
107
+ assuming the scene is broken. Confirmed reliable on the terminal/tmux backend;
108
+ the ipykernel backend has crashed outright on this call — treat as unproven
109
+ there.
110
+
111
+ ### Model controls facade
112
+
113
+ ```python
114
+ controls = _osg_repl_controls
115
+
116
+ controls.input.locked = True # swallow human mouse/keyboard input
117
+ controls.frames.target_fps = None # uncapped — never "lock the user out"
118
+ controls.frames.target_fps = 30
119
+ controls.frames.paused = True
120
+ print(controls.status)
121
+ ```
122
+
123
+ FRAME/resize/close events still pass through while input is locked.
124
+ `controls.window.always_on_top` uses `osgx.platform.alwaysOnTop()`. For a
125
+ short MP4 with no intermediate image files:
126
+
127
+ ```python
128
+ controls.capture.video("/tmp/take.mp4", fps=24, duration=5)
129
+ print(controls.capture.video_status)
130
+ ```
131
+
132
+ Samples in the final-draw callback on a monotonic wall-clock schedule and
133
+ streams RGB directly to FFmpeg; the call returns immediately. Pass
134
+ `lock_input=True` only when the capture must be deterministic. Readback is
135
+ still synchronous `readPixels()` in the draw callback, not yet PBO/fence-backed.
136
+ A resize during a take fails that capture.
137
+
138
+ ## 7. Backend choice: tmux vs. kernel
139
+
140
+ - **tmux**: visible, attachable (`tmux attach -t <name>`), the only backend
141
+ screenshot capture is confirmed on. `execute()`'s returned text can be a
142
+ stale snapshot for slow calls — always `capture()` again before assuming
143
+ something is stuck.
144
+ - **kernel**: structured JSON, better for programmatic inspection — but slow
145
+ calls have hung 120s+ on things that finish quickly via tmux, and async
146
+ screenshot capture has crashed the session outright. Less proven for this
147
+ project's live-viewer workflow.
148
+
149
+ If a session looks frozen on either backend, check rule 2 first — it's the
150
+ most common actual cause, not a backend bug.
151
+
152
+ ## 8. `viewer.frame()` doesn't release the GIL under `SingleThreaded`
153
+
154
+ Deliberate: OSG can drop the last `ref_ptr` on a GL-tracked object during
155
+ `frame()`'s own flush pass, and that pybind11 wrapper's destructor needs the
156
+ GIL to deregister. Releasing the GIL during `frame()` under `SingleThreaded`
157
+ (this project's standing default) aborts the process
158
+ (`PyGILState_Check()` failure, no Python traceback) the moment a live scene
159
+ graph is replaced while already running. `pyosg/pyosgViewer.cpp`'s `frame()`
160
+ binding only releases the GIL when `threadingModel != SingleThreaded` — don't
161
+ "fix" this by releasing it unconditionally.
162
+
163
+ ## 9. OSG's matrix and quaternion multiplication order is reversed vs. GLSL
164
+
165
+ OSG is row-vector (`v' = v * M`), GLSL is column-vector (`v' = M * v`). A
166
+ chain that reads left-to-right in GLSL (`A * B * C_vec`, apply `C` first)
167
+ must be written reversed in Python/OSG (`C_vec_source * B * A`). Translation
168
+ lives in **row 3**, not column 3. See [`ai/context-core.md`](../ai/context-core.md)
169
+ for the full derivation.
170
+
171
+ `osg.Quat` has the same reversed convention: `q1 * q2` applied to a vector
172
+ applies `q1` first, then `q2` — opposite of the standard Hamilton product.
173
+ Don't extend this rule to a new case by analogy; verify empirically:
174
+
175
+ ```python
176
+ combined = a * b
177
+ print(combined * known_vector) # does this match "apply a, then b", or the reverse?
178
+ ```
179
+
180
+ ## 10. `MatrixTransform.matrix` is a live C++ alias, not a snapshot
181
+
182
+ `getMatrix()` returns `const osg::Matrix&`; reading `.matrix` aliases the
183
+ transform's native matrix. Rebuilding a pose from "rest" each frame using a
184
+ bare read compounds every update:
185
+
186
+ ```python
187
+ rest = transform.matrix
188
+ transform.matrix = osg.Matrix.rotate(angle, axis) * rest # WRONG — rest aliases the live matrix
189
+ ```
190
+
191
+ Take an explicit copy once:
192
+
193
+ ```python
194
+ rest = osg.Matrix(transform.matrix)
195
+
196
+ def update(node, nv):
197
+ transform.matrix = osg.Matrix.rotate(angle, axis) * rest
198
+ return True
199
+ ```
200
+
201
+ `test/osg_Transform.py::test_matrixtransform_matrix_is_a_live_reference` locks
202
+ this down.
203
+
204
+ ## 11. Title any extra debug `osgViewer.Viewer()` window
205
+
206
+ Any REPL-created `osgViewer.Viewer()` beyond the user's primary session
207
+ viewer should be titled immediately after `realize()`, before pumping visible
208
+ frames — otherwise identical-looking windows become impossible to tell apart:
209
+
210
+ ```python
211
+ osgx.platform.setWindowTitle(viewer, "SOME-DESCRIPTIVE-LABEL")
212
+ ```
213
+
214
+ Pick a label describing what's different about this one. Close throwaway
215
+ windows explicitly (`viewer.close()`) once done.
216
+
217
+ ## 12. This binding reshapes OSG's C++ API into Python idiom — check for that surface before reaching for a raw `get`/`set`/`add` method
218
+
219
+ `pyosg`/`osgx` deliberately do not mirror OSG's C++ API 1:1. A method or
220
+ shape that isn't in OSG's C++ headers at all is often the *intended* way to
221
+ do something — don't fall back to a `get*`/`set*`/`add*`/`remove*` call just
222
+ because that's what the C++ docs show. Check `dir(obj)` or a live REPL probe
223
+ before assuming the raw method is the only path.
224
+
225
+ Recognizable patterns:
226
+
227
+ - Child/array-of-object members are Python sequences, not `add*`/`remove*`/
228
+ `insert*`/`get*At` method quartets: `node.children.append(x)`,
229
+ `geode.drawables[i]`, `program.shaders`, `geometry.primitiveSets`,
230
+ `view.eventHandlers` (`pyx::bind_proxy_property`, `etc/pybind11x.hpp`).
231
+ - `StateSet.uniforms`/`.attributes`/`.textureAttributes` are dict-like
232
+ (`MappingProxy`): `ss.uniforms["name"] = value` adds-or-replaces. Never
233
+ guard with `if "name" in ss.uniforms:` first — the assignment already
234
+ handles both cases.
235
+ - `Uniform` values read/write through `.value`, not `.getFloat()`/`[0]`. A
236
+ `vecN` assignment needs an explicit `osg.Vec3/Vec4(*t)` — a bare tuple
237
+ raises `GL_INVALID_OPERATION` at draw time, not at assignment time.
238
+ - `node.stateSet` is `getOrCreateStateSet()` (forces a new `StateSet` if
239
+ none exists). Use the module-level `osg.getStateSet(node)` instead when a
240
+ `None` result matters (read-only inspection, see `02-inspect.md`).
241
+ - A class-scoped `py::enum_<>` calls `.export_values()`: its members are
242
+ reachable both fully qualified (`GUIEventAdapter.EventType.KEYDOWN`) and
243
+ flattened onto the owning class (`GUIEventAdapter.KEYDOWN` — the form
244
+ every example in this repo actually uses), mirroring OSG's own unscoped
245
+ C++ enum. A MODULE-scoped enum does NOT do this (`osg.NotifySeverity` is
246
+ the one example) — `osg.INFO`/`osg.WARN` sitting bare at module scope
247
+ next to every class would be a real collision risk, so only the qualified
248
+ form (`osg.NotifySeverity.INFO`) exists for those.
249
+
250
+ `OVERVIEW.md` (repo root, regenerated by
251
+ `etc/scripts/generate-bindings-overview.py`) is the current census of every
252
+ bound symbol, but it reports raw pybind11 shape (`method`/`property`/`enum
253
+ value`/...) — it doesn't say which of those is the intended Pythonic entry
254
+ point for a task. Cross-check against this rule, not just against a symbol
255
+ merely existing in that file.
@@ -0,0 +1,81 @@
1
+ # Inspecting a scene graph you didn't build
2
+
3
+ When handed a live viewer or a loaded scene with no prior context, don't
4
+ reconstruct its structure by re-reading source or guessing at `StateSet`
5
+ contents. Fire `GatherVisitor` at it: every node's type/name, every attached
6
+ `Program` (plus each `Shader`'s type and a source preview), every `Uniform`
7
+ (name + type), all via `osg.notice()`.
8
+
9
+ ```python
10
+ from pyosg_visitor import GatherVisitor
11
+
12
+ osg.setNotifyLevel(osg.NotifySeverity.NOTICE) # GatherVisitor reports via osg.notice()
13
+
14
+ viewer.sceneData.accept(GatherVisitor())
15
+ ```
16
+
17
+ `pyosg_visitor.py` lives in `examples/`, alongside `pyosg_repl.py` — a plain
18
+ `from pyosg_visitor import GatherVisitor` resolves when your script also
19
+ lives there. From a subdirectory, fix `sys.path` first:
20
+
21
+ ```python
22
+ import pathlib, sys
23
+
24
+ examples_dir = pathlib.Path(__file__).resolve().parent.parent
25
+
26
+ if str(examples_dir) not in sys.path:
27
+ sys.path.insert(0, str(examples_dir))
28
+
29
+ from pyosg_visitor import GatherVisitor
30
+ ```
31
+
32
+ ## "Which Python variable is this?" — the `namespace=` hint
33
+
34
+ Pass `locals()`/`globals()` in and any gathered object whose `.addr` matches
35
+ a bare name in that dict gets an extra hint appended:
36
+
37
+ ```python
38
+ viewer.sceneData.accept(GatherVisitor(namespace=globals()))
39
+ ```
40
+
41
+ ```
42
+ [gather] Camera 'Composite HUD' (bound to local: 'hud_cam')
43
+ [gather] Uniforms: ['colorTex', 'depthTex', 'invProjectionMatrix', ...]
44
+ invProjectionMatrix: type=Type.FLOAT_MAT4 (bound to local: 'inv_proj_u')
45
+ colorTex: type=Type.INT
46
+ ```
47
+
48
+ This is a reverse lookup (`.addr` → matching names, computed once at
49
+ construction), not a live namespace walk — it only finds bare top-level
50
+ names. An object only reachable via a chain (e.g.
51
+ `hud_cam.stateSet.attributes[PROGRAM]`, never assigned its own name) gets no
52
+ hint; that's expected.
53
+
54
+ ## Reading a `StateSet`'s attached attributes
55
+
56
+ `osg.getStateSet(node)` is a **module-level function**, deliberately not a
57
+ `Node` method — `node.stateSet` (`getOrCreateStateSet()`) forces a new empty
58
+ `StateSet` onto every node it touches, which a read-only visitor must avoid.
59
+
60
+ `StateSet.attributes[]` is a `MappingProxy` keyed by `StateAttribute::Type`,
61
+ same shape as `.uniforms`/`.textureAttributes`, and does real polymorphic
62
+ downcasting (`ss.attributes[PROGRAM]` returns a real `osg.Program`):
63
+
64
+ ```python
65
+ ss.attributes.append(program) # key inferred from program.type
66
+ ss.attributes[osg.StateAttribute.PROGRAM] = program # explicit key, must match program.type
67
+ ss.attributes[osg.StateAttribute.PROGRAM] = (program, osg.StateAttribute.OVERRIDE)
68
+ ```
69
+
70
+ **Known limitation:** `.attributes[]` only ever addresses `member=0`
71
+ (`StateAttribute::getMember()`, overridden by `ClipPlane`/`Light` for
72
+ OpenGL's numbered fixed-function slots). Neither is bound in Python yet, so a
73
+ `StateSet` holding multiple attributes of the same `Type` at different
74
+ members currently only shows one via `.attributes[]`/`keys()`.
75
+
76
+ ## See also
77
+
78
+ - [`15-shader-hotswap.md`](15-shader-hotswap.md) — once you know which
79
+ `Program`/`Shader` you're looking at, patch its GLSL and hot-swap it live.
80
+ - [`20-object-lifetime.md`](20-object-lifetime.md) — scene *structure* (this
81
+ file) vs. object *lifetime* (is it really destroyed, not just detached).
@@ -0,0 +1,149 @@
1
+ # Headless event and update frames
2
+
3
+ Do not create an `osgViewer.Viewer` merely to make OSG callbacks run in a
4
+ unit test. A Viewer brings windows, graphics contexts, event polling, camera
5
+ setup, cull traversal, and draw traversal — only pay that tax when the
6
+ behavior under test actually depends on one of those things. For update
7
+ callbacks, event callbacks, deterministic animation, or Python/C++
8
+ trampoline dispatch, use OSG's existing visitors directly.
9
+
10
+ ## Pytest: use the fixture
11
+
12
+ `test/conftest.py` provides a fresh `simulate_frame` fixture per test. It
13
+ owns a `FrameStamp`, `EventQueue`, `EventVisitor`, `UpdateVisitor`, and a
14
+ recording Python `GUIActionAdapter`.
15
+
16
+ ```python
17
+ def test_update_callback(simulate_frame):
18
+ root = osg.Group()
19
+ seen = []
20
+
21
+ root.updateCallback = lambda node, visitor: seen.append(
22
+ visitor.frameStamp.simulationTime
23
+ )
24
+
25
+ simulate_frame.advance(simulationTime=1.25)
26
+ simulate_frame.traverseUpdate(root)
27
+
28
+ assert seen == [1.25]
29
+ ```
30
+
31
+ For event callbacks, queue events and traverse them:
32
+
33
+ ```python
34
+ root.eventCallback = lambda node, visitor: ...
35
+
36
+ simulate_frame.advance(simulationTime=0.0)
37
+ simulate_frame.events.keyPress(ord("A"))
38
+ simulate_frame.events.frame(0.0)
39
+ simulate_frame.traverseEvents(root)
40
+ ```
41
+
42
+ `simulate_frame.actions` records `requestRedraw()`,
43
+ `requestContinuousUpdate()`, and `requestWarpPointer()` calls. Assert against
44
+ `redraws`, `continuousUpdates`, or `pointerWarps`.
45
+
46
+ ## Forcing a real C++ virtual call
47
+
48
+ A direct call such as `manip.handle(event, actions)` is not trampoline-proof
49
+ — ordinary Python attribute lookup can find a Python subclass method without
50
+ entering the C++ virtual-dispatch path. Call through the bound OSG base class
51
+ instead, so C++ uses the object's vtable and reaches the pybind trampoline
52
+ exactly as real OSG dispatch would:
53
+
54
+ ```python
55
+ simulate_frame.dispatchEvent(manipulator, event)
56
+ CameraManipulator.updateCamera(manipulator, osg.Camera())
57
+ CameraManipulator.home(
58
+ manipulator,
59
+ simulate_frame.events.currentEventState,
60
+ simulate_frame.actions
61
+ )
62
+ ```
63
+
64
+ `test/osgGA_CameraManipulator.py` has the reference examples.
65
+
66
+ ## Wall-clock-driven callbacks
67
+
68
+ Some animation callbacks track their own elapsed time via `time.time()`
69
+ directly instead of reading `nv.frameStamp.simulationTime`
70
+ (`LiveUpdateCallback`, `ShrinkCallback`/`FallCallback`).
71
+ `simulate_frame.advance(simulationTime=...)` has no effect on these;
72
+ monkeypatch `time.time` to a controllable fake clock instead:
73
+
74
+ ```python
75
+ import time as time_module
76
+
77
+ class FakeClock:
78
+ def __init__(self, t=0.0):
79
+ self.t = t
80
+
81
+ def __call__(self):
82
+ return self.t
83
+
84
+ clock = FakeClock()
85
+ orig = time_module.time
86
+ time_module.time = clock
87
+
88
+ # ... build scene, attach callbacks ...
89
+
90
+ clock.t += 10.0 # jump well past any animation's duration
91
+ simulate_frame.traverseUpdate(root)
92
+
93
+ time_module.time = orig # restore — other tests need the real clock
94
+ ```
95
+
96
+ Still drive the tick through `simulate_frame.traverseUpdate(root)`, not by
97
+ calling each node's `updateCallback` directly — that exercises the real
98
+ `accept()`/traversal path instead of just the leaf callable in isolation.
99
+
100
+ ## Testing an example script's OSG-dependent helpers
101
+
102
+ `examples/*.py` mix pure-Python logic, OSG-dependent helpers, and an
103
+ `if __name__ == "__main__":` block that opens a real window — only that last
104
+ part needs a display. To unit-test the helpers without constructing a
105
+ `Viewer`, `exec()` the file's source up to (not including) that guard:
106
+
107
+ ```python
108
+ src = open("examples/pyosg-match4.py").read()
109
+ end = src.index('if __name__ == "__main__":')
110
+
111
+ ns = {"osg": osg, "osgGA": osgGA, "osgViewer": osgViewer, "__name__": "match4_headless"}
112
+
113
+ exec(src[:end], ns)
114
+
115
+ make_piece = ns["make_piece"]
116
+ ShrinkCallback = ns["ShrinkCallback"]
117
+ ```
118
+
119
+ This works because constructing scene-graph objects (`osg.Sphere`,
120
+ `osg.Geode`, `osg.MatrixTransform`, `osg.Uniform`, ...) needs no GL context —
121
+ only `viewer.frame()` does. Combined with the fake-clock trick above, this is
122
+ enough to headlessly drive a whole multi-frame animation sequence end to end
123
+ against real OSG objects.
124
+
125
+ ## Raw building blocks
126
+
127
+ - `osg.FrameStamp`
128
+ - `osgUtil.UpdateVisitor`
129
+ - `osgGA.EventQueue`
130
+ - `osgGA.EventVisitor`
131
+ - Python-subclassable `osgGA.GUIActionAdapter`
132
+ - `Node.updateCallback` and `Node.eventCallback`, each accepting either an
133
+ OSG `NodeCallback` or a plain Python callable
134
+
135
+ For a manual update traversal, increment the frame number, set reference and
136
+ simulation time, set the visitor's `frameStamp` and `traversalNumber`, then
137
+ call `root.accept(visitor)`. Reset and feed each event to `EventVisitor`
138
+ before accepting it on the root.
139
+
140
+ ## When a Viewer is still required
141
+
142
+ - a `View`'s own handler/manipulator ownership or scene/camera wiring;
143
+ - window/device polling, pointer-data reprojection, or close-window handling;
144
+ - cull traversal, camera ordering, render-to-texture execution, draw
145
+ callbacks, OpenGL object compilation, readback, swap buffers, or any real
146
+ GL state.
147
+
148
+ Headless simulation covers event and update traversal only — it is not fake
149
+ rendering.
@@ -0,0 +1,68 @@
1
+ # Camera manipulators in a live REPL session
2
+
3
+ ## Custom `osgGA.CameraManipulator` subclasses: overrides that actually dispatch
4
+
5
+ - **`setNode()`/`getNode()` dispatch to a Python override.** `View.setCameraManipulator(manip, resetPosition=True)` calls `manip->setNode(scene)` internally.
6
+ - **`.node` doesn't leak on reassignment** — a replaced, not accumulated, property slot.
7
+ - **`home(ea, aa)` doesn't crash** on a Python subclass override.
8
+
9
+ A subclass that doesn't override `setNode`/`getNode` at all still needs
10
+ explicit overrides storing into e.g. `self._node` if you want `self.node` to
11
+ reflect the real bound scene (radius/center), not a `1.0`/origin fallback.
12
+
13
+ ## Live camera-relative technique: aiming a light relative to the CURRENT view
14
+
15
+ Useful for interactively tuning a directional light "from the camera's
16
+ perspective" rather than in raw world-space azimuth, which looks different
17
+ every time the user orbits:
18
+
19
+ ```python
20
+ eye, center, up_v = v.camera.viewMatrix.getLookAt(1.0) # NOT the inverse — see gotcha below
21
+ fwd = center - eye; fwd.normalize()
22
+ right = fwd.cross(up_v); right.normalize()
23
+ up_v.normalize()
24
+
25
+ # Decompose an existing direction against this basis:
26
+ offset = osg.Vec3d(light_dir_u.value.x, light_dir_u.value.y, light_dir_u.value.z)
27
+ screen_right = offset.dot(right) # + = camera's right
28
+ screen_up = offset.dot(up_v) # + = above
29
+ toward_camera = -offset.dot(fwd) # + = pointing back at the viewer
30
+
31
+ # Build a new direction directly in this basis, e.g. 45deg upper-right, 45deg off-axis:
32
+ L_world = right * 0.5 + up_v * 0.5 - fwd * 0.70710678
33
+ L_world.normalize()
34
+ new_offset = L_world * offset.length() # preserve the original magnitude
35
+ ```
36
+
37
+ Always recompute the camera basis fresh, immediately before use — a basis
38
+ read even one call ago is stale if the user is actively orbiting between REPL
39
+ calls.
40
+
41
+ **Directional-light caveat:** there is no "point at X" for a sun-type light
42
+ (no position, no falloff) — the only lever is elevation, which changes *what
43
+ surfaces catch light*, not *where* it's aimed. Translate "point down at the
44
+ subject" into "increase the up-component, decrease the frontal component,"
45
+ not literal aiming.
46
+
47
+ ### Gotchas specific to this technique
48
+
49
+ - **`Matrixd.getLookAt(distance)` operates on the view matrix directly, NOT
50
+ its inverse.** Calling it on `osg.Matrix.inverse(v.camera.viewMatrix)`
51
+ silently returns plausible-looking-but-wrong eye/center/up, no error.
52
+ - **`Vec3.dot()`/arithmetic require matching types** — a uniform's `.value`
53
+ is often `Vec3f` while matrix math returns `Vec3d`; mixing raises
54
+ `TypeError: incompatible function arguments`. Cast explicitly:
55
+ `osg.Vec3d(v.x, v.y, v.z)`.
56
+ - **No `^` cross-product operator** — use `.cross()`.
57
+ - **`osg.setNotifyLevel` set too high floods the tmux pane** with X11 event
58
+ spam. Fix: `osg.setNotifyLevel(osg.NotifySeverity.WARN)`.
59
+
60
+ ## There is no `viewer.home()`
61
+
62
+ `osgViewer::ViewerBase`/`Viewer` has no `home()` in these bindings —
63
+ `viewer.cameraManipulator.home` exists but wants `(ea, aa)` event-handler args
64
+ not trivially available outside actual event dispatch. In practice this
65
+ doesn't matter: assigning `viewer.cameraManipulator = ...` *after*
66
+ `viewer.sceneData` is already set triggers the manipulator's own
67
+ `autoComputeHomePosition` framing automatically — no explicit home call
68
+ needed for the common "just frame the scene" case.