Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions bindings/pycvc/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ if(CVC_BUILD_PYCVC_CORE)
pycvc_dataprep:test_pycvc_dataprep.py
pycvc_volslice:test_pycvc_volslice.py
pycvc_nav_train:test_pycvc_nav_train.py
pycvc_integration:test_pycvc_integration.py)
pycvc_integration:test_pycvc_integration.py
pycvc_proxy_hooks:test_pycvc_proxy_hooks.py)
string(REPLACE ":" ";" _pair "${_t}")
list(GET _pair 0 _name)
list(GET _pair 1 _script)
Expand Down Expand Up @@ -332,58 +333,80 @@ if(CVC_BUILD_PYCVC_GL)
install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/pymod_gl/"
DESTINATION "${_PYCVC_SITEDIR}/pycvc_gl" COMPONENT pycvc_gl
FILES_MATCHING PATTERN "*.py")
# Mirror that INSTALLED package layout in the build tree (pkg/pycvc_gl/: the proxy as
# __init__.py, the extension inside, pymod_gl/*.py as submodules) and run the pycvc_gl
# tests against it, so they import pycvc_gl exactly as a consumer does. The flat
# pycvc_gl.py beside _pycvc_gl.so is a plain module, not a package, so
# `pycvc_gl.camera` et al. could never resolve from the build tree.
set(_PYCVC_GL_PKGROOT "${CMAKE_CURRENT_BINARY_DIR}/pkg")
set(_PYCVC_GL_PKG "${_PYCVC_GL_PKGROOT}/pycvc_gl")
file(GLOB _PYCVC_GL_PYMODS CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/pymod_gl/*.py")
add_custom_command(
OUTPUT "${_PYCVC_GL_PKGROOT}/pycvc_gl.staged"
COMMAND ${CMAKE_COMMAND} -E make_directory "${_PYCVC_GL_PKG}"
COMMAND ${CMAKE_COMMAND} -E copy_if_different "$<TARGET_FILE:pycvc_gl>" "${_PYCVC_GL_PKG}/"
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${CMAKE_CURRENT_BINARY_DIR}/pycvc_gl.py"
"${_PYCVC_GL_PKG}/__init__.py"
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${_PYCVC_GL_PYMODS} "${_PYCVC_GL_PKG}/"
COMMAND ${CMAKE_COMMAND} -E touch "${_PYCVC_GL_PKGROOT}/pycvc_gl.staged"
DEPENDS pycvc_gl ${_PYCVC_GL_PYMODS}
COMMENT "Staging the pycvc_gl package layout for the build-tree tests"
VERBATIM)
add_custom_target(pycvc_gl_pkg ALL DEPENDS "${_PYCVC_GL_PKGROOT}/pycvc_gl.staged")
# The package root first (so `pycvc_gl` is the package), then the binary dir (pycvc).
set(_PYCVC_GL_TEST_PYTHONPATH "${_PYCVC_GL_PKGROOT}:${CMAKE_CURRENT_BINARY_DIR}")
add_test(NAME pycvc_gl_smoke
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_pycvc_gl.py)
set_tests_properties(pycvc_gl_smoke PROPERTIES ENVIRONMENT
"PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
"PYTHONPATH=${_PYCVC_GL_TEST_PYTHONPATH};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
# AriRuntime: load demo.ari + run the frame loop from Python. Skips (exit 0) without a GL context.
add_test(NAME pycvc_ari
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_pycvc_ari.py)
set_tests_properties(pycvc_ari PROPERTIES ENVIRONMENT
"PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
"PYTHONPATH=${_PYCVC_GL_TEST_PYTHONPATH};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
# VTK Python bridge test — skips gracefully unless the vtk-python wrappers
# (vtkmodules) are importable; runs the vtkProp<->vtkActor round trip when they are.
add_test(NAME pycvc_vtk
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_pycvc_vtk.py)
set_tests_properties(pycvc_vtk PROPERTIES ENVIRONMENT
"PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
"PYTHONPATH=${_PYCVC_GL_TEST_PYTHONPATH};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
# Phase-5 zero-copy texture: node.set_texture + image.numpy() live edit +
# texture_modified (the strong VTK-inspection check self-skips without vtkmodules).
add_test(NAME pycvc_texture
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_pycvc_texture.py)
set_tests_properties(pycvc_texture PROPERTIES ENVIRONMENT
"PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
"PYTHONPATH=${_PYCVC_GL_TEST_PYTHONPATH};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
# CameraController: navigation + full cvc::state config (both directions),
# construct from an injected app OR a SceneRenderer, canonical viewer path.
add_test(NAME pycvc_gl_camera
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_pycvc_gl_camera.py)
set_tests_properties(pycvc_gl_camera PROPERTIES ENVIRONMENT
"PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
"PYTHONPATH=${_PYCVC_GL_TEST_PYTHONPATH};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
# world_units scene surface: GraphicsNode dimensions through the transform
# chain, the built-in grid/axis nodes, lights, VolRenNode + volren value types.
add_test(NAME pycvc_gl_world
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_pycvc_gl_world.py)
set_tests_properties(pycvc_gl_world PROPERTIES ENVIRONMENT
"PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
"PYTHONPATH=${_PYCVC_GL_TEST_PYTHONPATH};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
# Dear ImGui overlay + state-bound ui panels: binding presence + an offscreen
# overlay round-trip (self-skips without offscreen GL).
add_test(NAME pycvc_gl_imgui
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_pycvc_gl_imgui.py)
set_tests_properties(pycvc_gl_imgui PROPERTIES ENVIRONMENT
"PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
"PYTHONPATH=${_PYCVC_GL_TEST_PYTHONPATH};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
# ViewportManager / Viewport: picture-in-picture from Python — N viewports over
# separate scenes + a mirror, the input router (viewportAt, focus-follows-click
# into cvc::state, per-viewport camera), region tuples, frameRGB bytes
# (self-skips without offscreen GL).
add_test(NAME pycvc_gl_viewport
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_pycvc_gl_viewport.py)
set_tests_properties(pycvc_gl_viewport PROPERTIES ENVIRONMENT
"PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
"PYTHONPATH=${_PYCVC_GL_TEST_PYTHONPATH};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
# ChaseCamera parity: the native (C++ Track) chase camera returns the same pose
# as the pure-Python ChaseCamera on one position stream — the Python side of
# "the cameras work the same" (its C++ side is cvcgl_track_parity).
add_test(NAME pycvc_gl_chase_parity
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_pycvc_gl_chase_parity.py)
set_tests_properties(pycvc_gl_chase_parity PROPERTIES ENVIRONMENT
"PYTHONPATH=${CMAKE_CURRENT_BINARY_DIR};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
"PYTHONPATH=${_PYCVC_GL_TEST_PYTHONPATH};LD_LIBRARY_PATH=${_PYCVC_GL_TEST_LIBPATH}${_PYCVC_TEST_LIBPATH}:$ENV{LD_LIBRARY_PATH}")
endif() # CVC_BUILD_PYCVC_GL
7 changes: 4 additions & 3 deletions bindings/pycvc/pycvc_ari.i
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,11 @@
// Python — the Python surface loads .ari files instead.
%ignore cvc::gl::ariadne::AriRuntime::set_root;

// Keep the window/camera/overlay the AriRuntime borrows alive for its whole life.
// Keep the window/camera/overlay the AriRuntime borrows alive for its whole life. The ctor has a
// single signature, so its proxy takes NAMED parameters (no `args`; see the %pythonappend note in
// pycvc_gl.i).
%pythonappend cvc::gl::ariadne::AriRuntime::AriRuntime %{
if len(args) >= 3:
self._pycvc_view, self._pycvc_cam, self._pycvc_overlay = args[0], args[1], args[2]
self._pycvc_view, self._pycvc_cam, self._pycvc_overlay = view, cam, ui
%}

%include "cvc/gl/ariadne/AriRuntime.h"
29 changes: 22 additions & 7 deletions bindings/pycvc/pycvc_gl.i
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,16 @@ except Exception: # pragma: no cover -- VTK python bindings are optional

// A Python-CONSTRUCTED node (a director subclass built as MyNode(app, path,
// name)) must keep its app alive too — its ~SceneNode touches the app's state
// tree by raw reference. args[0] is the app the node ctor takes. (Nodes obtained
// from a SceneGraph get this via the SceneGraph appends below instead.)
// tree by raw reference. (Nodes obtained from a SceneGraph get this via the
// SceneGraph appends below instead.)
//
// NB on %pythonappend: SWIG emits `def f(self, *args)` ONLY for an overloaded
// function; a single-signature one gets NAMED parameters (`def f(self, ctx,
// statePath, name)`), where `args` is undefined and the append raises NameError
// on every call. So an overloaded ctor reads args[0], a single-signature one
// names its parameter — and adding/removing an overload flips which applies.
// test_pycvc_proxy_hooks.py fails the build's tests on any proxy that references
// an undefined name, so a flip cannot slip through silently.
%pythonappend cvc::gl::GraphicsNode::GraphicsNode %{
if args: self._pycvc_app = args[0]
%}
Expand All @@ -270,7 +278,7 @@ except Exception: # pragma: no cover -- VTK python bindings are optional
if args: self._pycvc_app = args[0]
%}
%pythonappend cvc::gl::VolRenNode::VolRenNode %{
if args: self._pycvc_app = args[0]
self._pycvc_app = ctx # single signature -> named-parameter proxy
%}
%pythonappend cvc::gl::LightNode::LightNode %{
if args: self._pycvc_app = args[0]
Expand All @@ -282,7 +290,7 @@ except Exception: # pragma: no cover -- VTK python bindings are optional
if args: self._pycvc_app = args[0]
%}
%pythonappend cvc::gl::VolSliceNode::VolSliceNode %{
if args: self._pycvc_app = args[0]
self._pycvc_app = ctx # single signature -> named-parameter proxy
%}

// ── SceneNode (abstract base): trim VTK / threading internals ───────────────
Expand Down Expand Up @@ -978,6 +986,13 @@ def _typed_node(sg, name):
// pickWorld has a double[3] OUT param SWIG can't express; re-exposed as pick_world
// below (returns an (x,y,z) tuple or None).
%ignore cvc::gl::SceneRenderer::pickWorld;
// Keep the borrowed SceneGraph alive: ~SceneRenderer detaches from it (close() ->
// ~ViewportManager -> SceneGraph::setRenderer), so a scene released FIRST — e.g. a
// function returning drops its `sg` local before `view` — was a use-after-free
// segfault at teardown. (Default args make this an overloaded, *args proxy.)
%pythonappend cvc::gl::SceneRenderer::SceneRenderer %{
if args: self._pycvc_scene = args[0]
%}
%include "cvc/gl/SceneRenderer.h"

%extend cvc::gl::SceneRenderer {
Expand Down Expand Up @@ -1109,9 +1124,9 @@ def _typed_node(sg, name):
%pythonappend cvc::gl::ViewportManager::activeViewport %{
if val is not None: val._pycvc_keepalive = self
%}
// addSceneViewport is a named-parameter proxy (no *args), so reference the
// `scene` argument by name — `args` is undefined here (only the *args ctor
// wrappers get it), which raised NameError the moment a viewport was added.
// addSceneViewport is a named-parameter proxy (single signature, no *args), so
// reference the `scene` argument by name — `args` is undefined here, which raised
// NameError the moment a viewport was added. (See the %pythonappend note above.)
%pythonappend cvc::gl::ViewportManager::addSceneViewport %{
if val is not None:
val._pycvc_keepalive = self
Expand Down
9 changes: 6 additions & 3 deletions bindings/pycvc/pycvc_imgui.i
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,15 @@
// the HUD to the shared state-driven controller, the same seam in C++ and Python.
%ignore cvc::gl::ImGuiOverlay::imguiContext; // opaque ImGuiContext*
// Keep the injected viewer alive: ~ImGuiOverlay detaches its window observers.
// Both are single-signature, i.e. NAMED-parameter proxies: reference the parameter
// by name (`args` does not exist there; see the %pythonappend note in pycvc_gl.i).
%pythonappend cvc::gl::ImGuiOverlay::ImGuiOverlay %{
if args: self._pycvc_keepalive = args[0]
self._pycvc_keepalive = viewer
%}
// Keep the attached camera alive: the overlay holds it by raw pointer.
// Keep the attached camera alive: the overlay holds it by raw pointer (None detaches
// and releases it).
%pythonappend cvc::gl::ImGuiOverlay::attachCamera %{
if args: self._pycvc_camera = args[0]
self._pycvc_camera = cam
%}
%include "cvc/gl/ImGuiOverlay.h"

Expand Down
9 changes: 8 additions & 1 deletion bindings/pycvc/test_pycvc_gl_imgui.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,14 @@ def check(what, ok):
ov.setDrawCallback(lambda: None) # a Python draw callback (reuses the callable typemap)
check("draw callback accepted", True)
ov.setUiScale(1.5)
check("uiScale round-trips", abs(ov.uiScale() - 1.5) < 1e-6)
if ov.enabled():
check("uiScale round-trips", abs(ov.uiScale() - 1.5) < 1e-6)
else:
# Inert overlay (libcvc built without CVC_ENABLE_IMGUI, or setup failed): every
# method is a documented no-op and uiScale() stays 1.0, so there is nothing to
# round-trip. (This check never ran before: ImGuiOverlay(view) raised NameError in
# its keepalive hook, which the except below reported as a SKIP.)
print(" skip: uiScale round-trip (overlay inert: enabled() == False)")
# exercise the bool controls; their inert-mode (CVC_ENABLE_IMGUI off) value is
# not asserted, only that the getters marshal a bool.
ov.setVisible(True)
Expand Down
93 changes: 93 additions & 0 deletions bindings/pycvc/test_pycvc_proxy_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Guard: no SWIG proxy function references an undefined name.

A %pythonappend / %pythonprepend body is pasted verbatim into the generated proxy,
whose signature SWIG picks per function: `def f(self, *args)` when the C++ function
is OVERLOADED, NAMED parameters (`def f(self, viewer)`) when it has a single
signature. A hook written for one shape breaks on the other -- `args[0]` in a
named-parameter proxy (or `viewer` in an *args one) is a NameError on EVERY call,
e.g. ImGuiOverlay(view) / AriRuntime(view, cam, ui) could not be constructed at all.
Adding or removing a C++ overload silently flips the shape, so check the generated
modules statically instead of hoping each hook is exercised by some test.

Resolution is deliberately simple: a name is defined if it is a parameter or bound
anywhere inside the function (nested lambdas/comprehensions included), a global of
the imported module, or a builtin.
"""

import ast
import builtins
import importlib

fails = 0


def _bound_names(fn):
names = set()
for node in ast.walk(fn):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
a = node.args
for arg in a.posonlyargs + a.args + a.kwonlyargs:
names.add(arg.arg)
if a.vararg:
names.add(a.vararg.arg)
if a.kwarg:
names.add(a.kwarg.arg)
if not isinstance(node, ast.Lambda):
names.add(node.name)
elif isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
names.add(node.id)
elif isinstance(node, (ast.Import, ast.ImportFrom)):
for alias in node.names:
names.add((alias.asname or alias.name).split(".")[0])
elif isinstance(node, ast.ExceptHandler) and node.name:
names.add(node.name)
elif isinstance(node, (ast.Global, ast.Nonlocal)):
names.update(node.names)
return names


def _functions(tree):
"""Yield (qualname, FunctionDef) for module-level functions and class methods."""
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
yield node.name, node
elif isinstance(node, ast.ClassDef):
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
yield "%s.%s" % (node.name, item.name), item


def check_module(modname):
global fails
try:
mod = importlib.import_module(modname)
except ImportError as exc:
print(" skip: %s not importable (%s)" % (modname, exc))
return
path = mod.__file__
with open(path, encoding="utf-8") as f:
tree = ast.parse(f.read(), filename=path)
known = set(vars(mod)) | set(dir(builtins))
bad = {} # (qualname, name) -> first line, one report per offending function+name
for qual, fn in _functions(tree):
bound = _bound_names(fn)
for node in ast.walk(fn):
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
if node.id not in bound and node.id not in known:
bad.setdefault((qual, node.id), node.lineno)
bad = ["%s:%d %s() uses undefined name %r" % (path, line, qual, name)
for (qual, name), line in sorted(bad.items(), key=lambda kv: kv[1])]
if bad:
fails += len(bad)
for b in bad:
print(" [FAIL] " + b)
else:
print(" ok: %s -- every proxy function's names resolve" % modname)


if __name__ == "__main__":
check_module("pycvc")
check_module("pycvc_gl")
if fails:
raise SystemExit("%d undefined-name reference(s) in SWIG proxies" % fails)
print("PASS")
7 changes: 7 additions & 0 deletions src/cvcGL/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,13 @@ add_executable(cvcgl_stage_caster_truth test/cvcgl_stage_caster_truth.cpp)
target_link_libraries(cvcgl_stage_caster_truth PRIVATE cvcGL)
add_test(NAME cvcgl_stage_caster_truth COMMAND cvcgl_stage_caster_truth)

# Multi-field StageLighting setters (setStage/setKey/setWash/applyPreset) must keep
# EVERY field: the rig's own change callback re-reads all fields from state, so
# writing them one at a time with it live kept only the first. Headless.
add_executable(cvcgl_stage_setters test/cvcgl_stage_setters.cpp)
target_link_libraries(cvcgl_stage_setters PRIVATE cvcGL)
add_test(NAME cvcgl_stage_setters COMMAND cvcgl_stage_setters)

# ViewportManager composites N Viewports (each its own vtkRenderer + camera +
# scene) as SetViewport/SetLayer regions in ONE vtkRenderWindow (one GL context —
# mandatory under WASM). Pins the picture-in-picture path the SceneRenderer test
Expand Down
Loading
Loading