from __future__ import annotations import argparse import ctypes import ctypes.wintypes import json import os import sys import time from pathlib import Path from PySide6.QtCore import QEvent, QObject, QPoint, Qt, qInstallMessageHandler from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QMessageBox, QWidget PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from step_editor.app import DEFAULT_MODEL_PATH, StepEditorWindow, _suppress_vtk_output_window def _rect_tuple(hwnd: int) -> tuple[int, int, int, int]: rect = ctypes.wintypes.RECT() if not ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)): return (0, 0, 0, 0) return (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom)) def _window_text(hwnd: int) -> str: length = ctypes.windll.user32.GetWindowTextLengthW(hwnd) buffer = ctypes.create_unicode_buffer(max(length + 1, 1)) ctypes.windll.user32.GetWindowTextW(hwnd, buffer, len(buffer)) return buffer.value def _class_name(hwnd: int) -> str: buffer = ctypes.create_unicode_buffer(256) ctypes.windll.user32.GetClassNameW(hwnd, buffer, len(buffer)) return buffer.value def _visible_native_windows_for_process(pid: int) -> list[dict[str, object]]: windows: list[dict[str, object]] = [] @ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) def callback(hwnd, _lparam): window_pid = ctypes.wintypes.DWORD() ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(window_pid)) if int(window_pid.value) != pid: return True if not ctypes.windll.user32.IsWindowVisible(hwnd): return True left, top, right, bottom = _rect_tuple(int(hwnd)) width = max(0, right - left) height = max(0, bottom - top) if width <= 0 or height <= 0: return True windows.append( { "hwnd": int(hwnd), "class": _class_name(int(hwnd)), "title": _window_text(int(hwnd)), "rect": [left, top, right, bottom], "size": [width, height], } ) return True ctypes.windll.user32.EnumWindows(callback, 0) return windows def _widget_text(widget: QWidget) -> str: if isinstance(widget, QMessageBox): return str(widget.text() or "") getter = getattr(widget, "text", None) if callable(getter): try: value = getter() if isinstance(value, str): return value except Exception: return "" return "" class WindowProbe(QObject): def __init__(self, output_dir: Path) -> None: super().__init__() self.output_dir = output_dir self.events: list[dict[str, object]] = [] self.native_samples: list[dict[str, object]] = [] self._native_seen: set[tuple[object, ...]] = set() def eventFilter(self, watched, event): if isinstance(watched, QWidget) and event.type() in { QEvent.Type.Show, QEvent.Type.Hide, QEvent.Type.WindowActivate, QEvent.Type.ToolTip, }: if watched.isWindow() or event.type() == QEvent.Type.ToolTip: self.events.append( { "event": event.type().name, "qt_class": type(watched).__name__, "object_name": watched.objectName(), "title": watched.windowTitle(), "text": _widget_text(watched), "is_window": watched.isWindow(), "visible": watched.isVisible(), "size": [watched.width(), watched.height()], "flags": int(watched.windowFlags()), } ) return False def sample_native(self, label: str) -> None: for item in _visible_native_windows_for_process(os.getpid()): key = (item["hwnd"], item["class"], item["title"], tuple(item["rect"])) if key in self._native_seen: continue self._native_seen.add(key) sample = dict(item) sample["label"] = label self.native_samples.append(sample) def grab_qt_windows(self, label: str) -> None: app = QApplication.instance() if app is None: return for index, widget in enumerate(app.topLevelWidgets()): if not widget.isVisible(): continue name = widget.objectName() or type(widget).__name__ safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in name) target = self.output_dir / f"{label}_qt_{index}_{safe_name}.png" widget.grab().save(str(target)) def grab_native_windows(self, label: str) -> None: app = QApplication.instance() if app is None: return screen = app.primaryScreen() if screen is None: return for index, item in enumerate(_visible_native_windows_for_process(os.getpid())): hwnd = int(item["hwnd"]) safe_class = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in str(item["class"])) target = self.output_dir / f"{label}_native_{index}_{safe_class}_{hwnd}.png" screen.grabWindow(hwnd).save(str(target)) def write_log(self) -> None: payload = { "qt_events": self.events, "native_samples": self.native_samples, } (self.output_dir / "transient_windows_log.json").write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8", ) def _pump(app: QApplication, probe: WindowProbe, label: str, ms: int) -> None: deadline = time.monotonic() + ms / 1000.0 while time.monotonic() < deadline: app.processEvents() probe.sample_native(label) time.sleep(0.005) def _find_face_click_point(window: StepEditorWindow) -> QPoint: width = max(1, window.vtk_widget.width()) height = max(1, window.vtk_widget.height()) fractions = [0.5, 0.42, 0.58, 0.35, 0.65, 0.28, 0.72] for fy in fractions: for fx in fractions: x = int(width * fx) y_qt = int(height * fy) y_vtk = int(height - y_qt) target = window._pick_selection_target("Face", x, y_vtk) if target is not None: return QPoint(x, y_qt) return QPoint(width // 2, height // 2) def _click_first_property_card(window: StepEditorWindow) -> bool: rows = getattr(window, "property_card_rows", {}) if not isinstance(rows, dict): return False for widgets in rows.values(): card = widgets.get("card") if isinstance(widgets, dict) else None if isinstance(card, QWidget) and card.isVisible(): QTest.mouseClick(card, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, QPoint(20, max(2, card.height() // 2))) return True return False def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--output-dir", default="assets/screenshots/transient_probe") parser.add_argument("--model", default=str(DEFAULT_MODEL_PATH)) parser.add_argument("--offscreen", action="store_true") args = parser.parse_args() if args.offscreen: os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) qt_messages: list[str] = [] def message_handler(_mode, _context, message): text = str(message) qt_messages.append(text) print(text, file=sys.stderr) qInstallMessageHandler(message_handler) _suppress_vtk_output_window() app = QApplication(["transient-window-probe"]) probe = WindowProbe(output_dir) app.installEventFilter(probe) window = StepEditorWindow(Path(args.model), background_load=False) window.show() window.resize(1280, 820) _pump(app, probe, "after_show", 300) window.load_step(Path(args.model), background=False) _pump(app, probe, "after_load", 800) window.render_window.Render() _pump(app, probe, "after_render", 300) probe.grab_qt_windows("after_render") probe.grab_native_windows("after_render") window._set_selection_mode("Face") point = _find_face_click_point(window) QTest.mouseClick(window.vtk_widget, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier, point) _pump(app, probe, "after_face_click", 900) probe.grab_qt_windows("after_face_click") probe.grab_native_windows("after_face_click") clicked_card = _click_first_property_card(window) _pump(app, probe, "after_property_click", 900) probe.grab_qt_windows("after_property_click") probe.grab_native_windows("after_property_click") (output_dir / "probe_summary.json").write_text( json.dumps( { "model": str(Path(args.model)), "face_click_point": [point.x(), point.y()], "selected_kind": getattr(window, "selected_kind", None), "selected_face_id": getattr(window, "selected_face_id", None), "clicked_property_card": clicked_card, "qt_message_count": len(qt_messages), "qt_messages": qt_messages[-50:], }, ensure_ascii=False, indent=2, ), encoding="utf-8", ) probe.write_log() window.close() _pump(app, probe, "after_close", 100) app.quit() print(f"transient window probe wrote {output_dir}") return 0 if __name__ == "__main__": raise SystemExit(main())