OpenSceneGraph 0.1.2__cp314-cp314-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.cp314-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.cp314-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
|
Binary file
|
|
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.
|