Files

276 lines
9.6 KiB
Python
Raw Permalink Normal View History

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())