"""Probe the real GL stack from inside a Qt window and write a diagnostic report. Stage 1 deliverable (``docs/architecture/studio-3d-stages.md``). It creates an **actual** Qt widget and reads the context that Qt actually obtained — asking the system whether ``libGL`` exists proves nothing, and the ``offscreen`` platform cannot stand in for a board (measured: offscreen answers "this plugin does not support createPlatformOpenGLContext"). Exit code is the gate: 0 only for a context that a GPU is really driving. A software rasteriser (llvmpipe and friends) reports 3 unless ``--allow-software`` is passed, so a board cannot be signed off on one by accident. Usage:: python tools/scene_assets/probe_gl.py --profile desktop33 --output gl-report.json python tools/scene_assets/probe_gl.py --profile gles30 --windowed """ from __future__ import annotations import argparse import json import os import platform import sys import time from pathlib import Path #: Exit codes: distinct values so a CI script can tell the two failures apart. EXIT_OK = 0 EXIT_SOFTWARE = 3 EXIT_NO_CONTEXT = 4 def _bootstrap_paths() -> None: here = Path(__file__).resolve() repo = here.parents[2] for path in (repo / "apps" / "studio" / "src", repo / "shared" / "src"): if path.is_dir() and str(path) not in sys.path: sys.path.insert(0, str(path)) def collect(profile: str, *, keep_open_s: float) -> dict: """Create a real window, let Qt initialise it, and report what it got.""" from PyQt5.QtCore import QTimer from PyQt5.QtWidgets import QApplication, QOpenGLWidget from orisys_studio.scene3d.gl_backend import ( GLDispatch, GLRenderer, ThreeDUnsupported, configure_surface_format, lut_rgba, ) configure_surface_format(profile) # type: ignore[arg-type] app = QApplication.instance() or QApplication(sys.argv) report: dict = { "profile_requested": profile, "platform": { "qt_platform": app.platformName(), "python": sys.version.split()[0], "os": f"{platform.system()} {platform.release()}", }, } try: from OpenGL import __version__ as pyopengl_version report["platform"]["pyopengl"] = str(pyopengl_version) except Exception: # noqa: BLE001 report["platform"]["pyopengl"] = "missing" class Probe(QOpenGLWidget): def __init__(self) -> None: super().__init__() self.renderer = GLRenderer() self.dispatch = GLDispatch() self.error: dict | None = None self.ready = False def initializeGL(self) -> None: try: capabilities = self.dispatch.bind_current_context(self.context()) self.renderer.initialize(capabilities) except ThreeDUnsupported as exc: caps = exc.capabilities self.report_caps(caps) self.error = {"code": exc.diagnostic.code, "message": exc.diagnostic.message} except Exception as exc: # noqa: BLE001 self.error = {"code": "gl_unsupported", "message": f"{type(exc).__name__}: {exc}"} else: self.report_caps(capabilities) self.ready = True def report_caps(self, capabilities) -> None: from PyQt5.QtGui import QSurfaceFormat fmt: QSurfaceFormat = self.context().format() if self.context() else QSurfaceFormat() ratio = float(self.devicePixelRatioF() or 1.0) report["context"] = { "valid": bool(self.context().isValid()) if self.context() else False, "vendor": capabilities.vendor, "renderer": capabilities.renderer, "gl_version": capabilities.gl_version, "glsl_version": capabilities.glsl_version, "profile_used": capabilities.profile, "software_renderer": capabilities.software_renderer, "max_texture_size": capabilities.max_texture_size, "qt_format": { "version": f"{fmt.majorVersion()}.{fmt.minorVersion()}", "renderable_type": int(fmt.renderableType()), "profile": int(fmt.profile()), "depth_buffer": int(fmt.depthBufferSize()), "samples": int(fmt.samples()), }, "device_pixel_ratio": ratio, "framebuffer_px": [ int(round(self.width() * ratio)), int(round(self.height() * ratio)), ], "diagnostics": [ {"code": item.code, "severity": item.severity, "message": item.message} for item in capabilities.diagnostics ], } report["lut_rgba_bytes"] = int(lut_rgba("sequential").nbytes) report["renderer_initialized"] = bool(self.renderer.is_initialized) widget = Probe() widget.resize(320, 240) if keep_open_s >= 0: widget.show() deadline = time.perf_counter() + max(keep_open_s, 0.3) while time.perf_counter() < deadline and not widget.ready: app.processEvents() time.sleep(0.01) app.processEvents() if widget.error is not None: report["error"] = widget.error try: widget.makeCurrent() widget.renderer.release() widget.doneCurrent() except Exception as exc: # noqa: BLE001 report["release_error"] = f"{type(exc).__name__}: {exc}" report["released"] = True widget.deleteLater() app.processEvents() QTimer.singleShot(0, app.quit) app.processEvents() return report def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( "--profile", choices=("desktop33", "gles30"), default="desktop33", help="the start-up profile to probe (one per process)", ) parser.add_argument( "--output", type=Path, default=None, help="write the diagnostic report here as JSON", ) parser.add_argument( "--windowed", action="store_true", help="keep a visible window instead of a short-lived one", ) parser.add_argument( "--allow-software", action="store_true", help="treat a software rasteriser as a pass (for a development box only)", ) args = parser.parse_args(argv) _bootstrap_paths() report = collect(args.profile, keep_open_s=0.6 if args.windowed else 0.3) context = report.get("context") if not context or not context.get("valid"): report["verdict"] = "no_context" code = EXIT_NO_CONTEXT elif context.get("software_renderer") and not args.allow_software: report["verdict"] = "software_renderer" code = EXIT_SOFTWARE elif not report.get("renderer_initialized"): report["verdict"] = "shader_failed" code = EXIT_NO_CONTEXT else: report["verdict"] = "ok" code = EXIT_OK text = json.dumps(report, indent=2, ensure_ascii=False) if args.output is not None: args.output.write_text(text + "\n", encoding="utf-8") print(text) print( f"\nverdict={report['verdict']} profile={args.profile} " f"renderer={(context or {}).get('renderer', '?')}", file=sys.stderr, ) return code if __name__ == "__main__": sys.exit(main())