more progress, but some of them are wip

This commit is contained in:
2026-07-07 16:48:44 +08:00
parent 431ca6e7b1
commit 0f484b78bb
10 changed files with 986 additions and 93 deletions

View File

@@ -42,6 +42,9 @@ add_executable(qml-sg-mcp-bridge
src/gammaray_session.h
src/scenegraph_tools.cpp
src/scenegraph_tools.h
src/material_interface.cpp
src/material_interface.h
src/quickinspector_types.h
)
target_link_libraries(qml-sg-mcp-bridge PRIVATE
@@ -55,6 +58,11 @@ target_link_libraries(qml-sg-mcp-bridge PRIVATE
Qt6::McpCommon
)
# Add GammaRay source common/ for message.h (not in installed headers)
target_include_directories(qml-sg-mcp-bridge PRIVATE
/home/blumia/Sources/GammaRay/common
)
# Runtime: qtmcp builds SHARED libs + the stdio backend plugin into the build
# tree. Set RPATH so the bridge finds libQt6Mcp*.so, and set QT_PLUGIN_PATH at
# run time (see run.sh) so Qt loads mcpserverbackend/libqmcpserverstdio.so.

View File

@@ -12,8 +12,13 @@
#include <QCoreApplication>
#include <QEventLoop>
#include <QItemSelectionModel>
#include <QTimer>
// Defined in material_interface.cpp — registers the MaterialExtensionInterface
// client factory so ObjectBroker can create our proxy for async getShader.
extern void registerMaterialExtensionFactory();
GammaRaySession::GammaRaySession(QObject *parent)
: QObject(parent)
{
@@ -61,6 +66,11 @@ GammaRaySession::~GammaRaySession() = default;
void GammaRaySession::initOnce()
{
GammaRay::ClientConnectionManager::init();
// Register our MaterialExtensionInterface proxy factory. The real factory
// is in QuickInspectorUiFactory::initUi() (a GUI plugin we don't load).
// Without this, ObjectBroker::object<MaterialExtensionInterface *>() would
// Q_ASSERT (no factory registered for the IID).
registerMaterialExtensionFactory();
}
void GammaRaySession::connectToHost(const QUrl &url)
@@ -123,6 +133,13 @@ QAbstractItemModel *GammaRaySession::model(const QString &name) const
return GammaRay::ObjectBroker::model(name);
}
QItemSelectionModel *GammaRaySession::selectionModel(QAbstractItemModel *model) const
{
if (m_state != State::Ready || !model)
return nullptr;
return GammaRay::ObjectBroker::selectionModel(model);
}
QString GammaRaySession::stateString(State s)
{
switch (s) {

View File

@@ -35,6 +35,7 @@
QT_BEGIN_NAMESPACE
class QAbstractItemModel;
class QItemSelectionModel;
QT_END_NAMESPACE
namespace GammaRay {
@@ -89,6 +90,13 @@ public:
// disconnect/reconnect.
QAbstractItemModel *model(const QString &name) const;
// Retrieve a synced selection model for the given model. Returns nullptr if
// not ready or no selection factory is registered. The selection model
// (a SelectionModelClient on the wire) automatically syncs selection
// changes to the probe, which triggers PropertyController::setObject() on
// the probe side and populates per-node sub-models (geometry, material, etc.).
QItemSelectionModel *selectionModel(QAbstractItemModel *model) const;
signals:
void ready();
void disconnected();

View File

@@ -76,22 +76,39 @@ int main(int argc, char **argv)
SceneGraphTools tools(&session, &server);
server.registerToolSet(&tools, {
// Connection management. The bridge no longer hard-requires --connect:
// a client calls connectProbe() to (re)establish a probe connection,
// and any introspection tool will also auto-reconnect if a URL was
// previously established. connectProbeDefault() is the zero-arg entry
// point for the common 127.0.0.1:11732 case.
// Connection management
{ QStringLiteral("connectProbe"), QStringLiteral("Connect to a GammaRay probe. host defaults to 127.0.0.1; pass port=0 to use the default 11732.") },
{ QStringLiteral("connectProbe/host"), QStringLiteral("Probe host (default 127.0.0.1)") },
{ QStringLiteral("connectProbe/port"), QStringLiteral("Probe port (default 11732; pass 0 to use default)") },
{ QStringLiteral("connectProbeDefault"), QStringLiteral("Connect to a GammaRay probe at 127.0.0.1:11732 (convenience for connectProbe with no args)") },
{ QStringLiteral("disconnectProbe"), QStringLiteral("Drop the current probe connection and forget the URL") },
{ QStringLiteral("probeStatus"), QStringLiteral("Report current probe connection state, last URL and last error") },
// Introspection
// Window-level navigation
{ QStringLiteral("listQuickWindows"), QStringLiteral("List QQuickWindows in the target app") },
{ QStringLiteral("selectQuickWindow"), QStringLiteral("Select a Quick window (by index into listQuickWindows) so the scene graph is introspected for it") },
{ QStringLiteral("selectQuickWindow/index"), QStringLiteral("0-based index of the window in listQuickWindows") },
{ QStringLiteral("listScenegraphNodes"), QStringLiteral("List the QSGNode tree of the selected Quick window") },
// SG node selection
// TODO: re-enable when the selection-sync / sub-model-population bug is fixed
// { QStringLiteral("selectScenegraphNode"), QStringLiteral("Select a SG node by address (from listScenegraphNodes) so geometry/material sub-models populate for it") },
// { QStringLiteral("selectScenegraphNode/address"), QStringLiteral("Node address (e.g. 0x56396e6d7400, from listScenegraphNodes)") },
// Geometry
// { QStringLiteral("getNodeVertices"), QStringLiteral("Get vertex data for a GeometryNode (vertices, attributes, isCoordinate flags)") },
// { QStringLiteral("getNodeVertices/address"), QStringLiteral("Node address (must be a GeometryNode)") },
// { QStringLiteral("getNodeAdjacency"), QStringLiteral("Get adjacency/index data for a GeometryNode (drawing mode + index list)") },
// { QStringLiteral("getNodeAdjacency/address"), QStringLiteral("Node address (must be a GeometryNode)") },
// Material/Shader
// { QStringLiteral("getMaterialShaders"), QStringLiteral("List shader stages (Vertex, Fragment, ...) for a node's material") },
// { QStringLiteral("getMaterialShaders/address"), QStringLiteral("Node address (must be a GeometryNode with a material)") },
// { QStringLiteral("getShaderSource"), QStringLiteral("Get shader source code for a shader stage (async: waits for probe response)") },
// { QStringLiteral("getShaderSource/row"), QStringLiteral("Row index from getMaterialShaders (0 = first shader stage)") },
// { QStringLiteral("getMaterialProperties"), QStringLiteral("Get material properties (name/value pairs) for a node's material") },
// { QStringLiteral("getMaterialProperties/address"), QStringLiteral("Node address (must be a GeometryNode with a material)") },
// Rendering visualization
{ QStringLiteral("setRenderMode"), QStringLiteral("Set custom render mode for visualization") },
{ QStringLiteral("setRenderMode/mode"), QStringLiteral("Render mode: NormalRendering, VisualizeClipping, VisualizeOverdraw, VisualizeBatches, VisualizeChanges, or VisualizeTraces") },
{ QStringLiteral("setSlowMode"), QStringLiteral("Toggle slow animations mode (renders continuously instead of on-demand)") },
{ QStringLiteral("setSlowMode/enabled"), QStringLiteral("true to enable slow mode, false to disable") },
});
QObject::connect(&server, &QMcpServer::finished, &app, &QCoreApplication::quit);

View File

@@ -0,0 +1,54 @@
/*
material_interface.cpp — client-side MaterialExtension proxy.
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
SPDX-License-Identifier: GPL-2.0-or-later
*/
#include "material_interface.h"
#include <common/endpoint.h>
#include <common/objectbroker.h>
using namespace GammaRay;
// Concrete client proxy: getShader() forwards to the probe via invokeObject.
// The probe-side MaterialExtension::getShader(row) emits gotShader(source),
// which the protocol delivers back to this proxy's gotShader signal.
class MaterialExtensionProxy : public GammaRay::MaterialExtensionInterface
{
Q_OBJECT
public:
explicit MaterialExtensionProxy(const QString &name, QObject *parent = nullptr)
: MaterialExtensionInterface(name, parent)
{
// Self-register in the ObjectBroker so the protocol can find us by name
// to deliver the gotShader signal. This mirrors the real
// MaterialExtensionInterface ctor (materialextensioninterface.cpp:23).
ObjectBroker::registerObject(name, this);
}
public slots:
void getShader(int row) override
{
auto *ep = Endpoint::instance();
if (ep)
ep->invokeObject(name(), "getShader", QVariantList{row});
}
};
// Factory callback for ObjectBroker — called when ObjectBroker::objectInternal()
// needs to create a client-side proxy for a MaterialExtensionInterface.
static QObject *createMaterialExtensionProxy(const QString &name, QObject *parent)
{
return new MaterialExtensionProxy(name, parent);
}
// Public registration function — called from GammaRaySession::initOnce().
void registerMaterialExtensionFactory()
{
ObjectBroker::registerClientObjectFactoryCallback<MaterialExtensionInterface *>(
createMaterialExtensionProxy);
}
#include "material_interface.moc"

View File

@@ -0,0 +1,59 @@
/*
material_interface.h — minimal MaterialExtensionInterface proxy for the bridge.
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
SPDX-License-Identifier: GPL-2.0-or-later
The real MaterialExtensionInterface (plugins/quickinspector/materialextension/
materialextensioninterface.h) is NOT in the installed GammaRay headers. But the
bridge needs to:
1. Call getShader(row) — forwards via Endpoint::invokeObject to the probe.
2. Receive the gotShader(QString) signal — delivered by the GammaRay protocol
to a client-side proxy object registered under the same name.
This class replicates the minimal interface: a QObject with the `gotShader`
signal and `getShader` slot, the same IID ("com.kdab.GammaRay.MaterialExtensionInterface"),
and a constructor that self-registers via ObjectBroker::registerObject(name, this)
— exactly like the real MaterialExtensionInterface base class does.
The GammaRay protocol forwards signal emissions by object name + signal
signature. As long as our proxy has `gotShader(QString)` in its meta-object
(guaranteed by Q_OBJECT + signals:), the signal arrives correctly.
*/
#ifndef MATERIAL_INTERFACE_H
#define MATERIAL_INTERFACE_H
#include <QObject>
#include <QString>
namespace GammaRay {
class MaterialExtensionInterface : public QObject
{
Q_OBJECT
public:
explicit MaterialExtensionInterface(const QString &name, QObject *parent = nullptr)
: QObject(parent)
, m_name(name)
{
}
const QString &name() const { return m_name; }
signals:
void gotShader(const QString &shaderSource);
public slots:
virtual void getShader(int row) = 0;
protected:
QString m_name;
};
} // namespace GammaRay
QT_BEGIN_NAMESPACE
Q_DECLARE_INTERFACE(GammaRay::MaterialExtensionInterface,
"com.kdab.GammaRay.MaterialExtensionInterface")
QT_END_NAMESPACE
#endif // MATERIAL_INTERFACE_H

View File

@@ -0,0 +1,50 @@
/*
quickinspector_types.h — minimal enum definitions matching the probe's
QuickInspectorInterface, for use with Endpoint::invokeObject.
SPDX-FileCopyrightText: 2026 The GammaRay MCP Bridge Authors
SPDX-License-Identifier: GPL-2.0-or-later
The bridge cannot include the real quickinspectorinterface.h (not in installed
headers — see PLAN.md open question #1). But setCustomRenderMode() takes a
RenderMode enum by value, and Endpoint::invokeObject serializes QVariant args
via QDataStream which uses the QVariant typeName() for custom types. So we
need a type whose fully-qualified name matches "GammaRay::QuickInspectorInterface::RenderMode"
exactly, and which is registered via Q_DECLARE_METATYPE on both sides.
This header defines just that — a nested enum inside a class with the same
namespace/class structure. Q_DECLARE_METATYPE registers it with the matching
type name, enabling cross-process QVariant serialization.
*/
#ifndef QUICKINSPECTOR_TYPES_H
#define QUICKINSPECTOR_TYPES_H
#include <QMetaType>
#include <QObject>
namespace GammaRay {
// Minimal stand-in for the real QuickInspectorInterface — just the RenderMode
// enum. Do NOT add Q_OBJECT (we don't need MOC for this header-only class;
// Q_DECLARE_METATYPE below handles metatype registration).
class QuickInspectorInterface
{
public:
// Values MUST match the probe's enum (quickinspectorinterface.h:52-66).
enum RenderMode
{
NormalRendering,
VisualizeClipping,
VisualizeOverdraw,
VisualizeBatches,
VisualizeChanges,
VisualizeTraces,
};
};
} // namespace GammaRay
Q_DECLARE_METATYPE(GammaRay::QuickInspectorInterface::RenderMode)
#endif // QUICKINSPECTOR_TYPES_H

View File

@@ -7,12 +7,18 @@
#include "scenegraph_tools.h"
#include "gammaray_session.h"
#include "material_interface.h"
#include "quickinspector_types.h"
#include <common/endpoint.h>
#include <endpoint.h>
#include <message.h>
#include <protocol.h>
#include <objectbroker.h>
#include <QAbstractItemModel>
#include <QCoreApplication>
#include <QEventLoop>
#include <QItemSelectionModel>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
@@ -23,13 +29,17 @@
// Model registration names (from plugins/quickinspector/quickinspector.cpp).
static const char *kQuickWindowModel = "com.kdab.GammaRay.QuickWindowModel";
static const char *kQuickSceneGraphModel = "com.kdab.GammaRay.QuickSceneGraphModel";
static const char *kBaseName = "com.kdab.GammaRay.QuickSceneGraph";
static const char *kMaterialIface = "com.kdab.GammaRay.MaterialExtensionInterface";
static const char *kQuickInspectorIface = "com.kdab.GammaRay.QuickInspectorInterface/1.0";
// Custom roles from sggeometrymodel.h / materialshadermodel.h
static constexpr int kIsCoordinateRole = 257;
static constexpr int kDrawingModeRole = 257;
static constexpr int kRenderRole = 258;
// --- async helpers (from earlier scaffold) ---
// RemoteModel populates asynchronously from the probe and fetches cell data
// lazily: data() returns a "Loading..." placeholder and queues a server fetch;
// the real value arrives later via dataChanged. So we must (a) trigger the
// fetches by calling data(), and (b) wait for the values to settle. We poll in
// a local event loop until the first column display is non-empty AND not the
// "Loading..." placeholder, or until the total budget elapses.
static void waitForData(const QAbstractItemModel *m, int column, int timeoutMs)
{
if (!m)
@@ -39,7 +49,6 @@ static void waitForData(const QAbstractItemModel *m, int column, int timeoutMs)
const int step = 150;
while (waited < timeoutMs) {
if (m->rowCount() > 0) {
// Trigger/refresh the cell fetch for the first row's target column.
const auto v = m->data(m->index(0, column), Qt::DisplayRole).toString();
if (!v.isEmpty() && v != QLatin1String("Loading..."))
return;
@@ -55,7 +64,6 @@ static QJsonObject errorJson(const QString &msg)
return { { QStringLiteral("error"), msg } };
}
// Recursive tree walker for tree-shaped remote models.
static QJsonArray walkChildren(const QAbstractItemModel *m, const QModelIndex &parent, int depth)
{
QJsonArray out;
@@ -76,13 +84,6 @@ static QJsonArray walkChildren(const QAbstractItemModel *m, const QModelIndex &p
return out;
}
// RemoteModel fetches cell data AND child row counts lazily from the probe:
// data()/rowCount()/hasChildren() trigger server fetches and return a
// placeholder ("Loading..." / 0 / false) until the response arrives. So to get
// a populated tree we must (1) prime: walk the model touching every cell and
// rowCount() to queue all fetches, (2) let the event loop run so responses
// arrive, (3) read. We repeat prime+settle a few rounds so deeper levels
// become reachable as their parents' row counts arrive.
static void primeTree(const QAbstractItemModel *m, const QModelIndex &parent, int depth)
{
if (depth <= 0)
@@ -102,6 +103,101 @@ static void settle(QEventLoop &loop, int ms)
loop.exec();
}
// Count how many cells in the tree are still "Loading..." (column 0).
// Used to decide whether more prime+settle rounds are needed.
static int countLoading(const QAbstractItemModel *m, const QModelIndex &parent, int depth)
{
if (depth <= 0)
return 0;
int count = 0;
for (int r = 0; r < m->rowCount(parent); ++r) {
const QModelIndex idx = m->index(r, 0, parent);
const QString v = m->data(idx, Qt::DisplayRole).toString();
if (v == QLatin1String("Loading..."))
++count;
if (m->hasChildren(idx))
count += countLoading(m, idx, depth - 1);
}
return count;
}
// Signal-driven tree loading: prime the tree, then settle until no
// dataChanged/rowsInserted signals arrive for quietMs. Repeat up to maxRounds
// or until no cells are "Loading...". After the main rounds, do one final
// long settle (finalSettleMs) to let any in-flight fetch responses arrive,
// then re-check. This handles the common case where the deepest cells' fetch
// responses arrive just after the quiet window expires.
static void primeAndWait(const QAbstractItemModel *m, int maxRounds, int settleMs, int quietMs,
int finalSettleMs = 2000)
{
QEventLoop loop;
for (int round = 0; round < maxRounds; ++round) {
bool signalActivity = false;
const auto dcConn = QObject::connect(m, &QAbstractItemModel::dataChanged,
[&signalActivity]() { signalActivity = true; });
const auto riConn = QObject::connect(m, &QAbstractItemModel::rowsInserted,
[&signalActivity]() { signalActivity = true; });
primeTree(m, QModelIndex(), 16);
int quiet = 0;
while (quiet < quietMs) {
signalActivity = false;
QTimer::singleShot(settleMs, &loop, &QEventLoop::quit);
loop.exec();
quiet += settleMs;
if (signalActivity)
quiet = 0;
}
QObject::disconnect(dcConn);
QObject::disconnect(riConn);
if (countLoading(m, QModelIndex(), 16) == 0)
return;
}
// Final long settle: let any in-flight fetch responses arrive, then re-prime.
if (countLoading(m, QModelIndex(), 16) > 0) {
settle(loop, finalSettleMs);
primeTree(m, QModelIndex(), 16);
settle(loop, finalSettleMs / 2);
}
}
// Recursively search the SG tree for a node whose column-0 display string
// matches the given address. Primes the tree as it goes so deeper levels
// become available. Returns the matching index (column 0) or invalid.
static QModelIndex findNodeInTree(const QAbstractItemModel *m, const QModelIndex &parent,
const QString &address, int depth, QEventLoop &loop)
{
if (depth <= 0)
return {};
for (int r = 0; r < m->rowCount(parent); ++r) {
const QModelIndex idx = m->index(r, 0, parent);
const QString addr = m->data(idx, Qt::DisplayRole).toString();
if (addr == address)
return idx;
// If this node is still "Loading...", settle and retry
if (addr == QLatin1String("Loading...")) {
settle(loop, 300);
const QString addr2 = m->data(idx, Qt::DisplayRole).toString();
if (addr2 == address)
return idx;
}
if (m->hasChildren(idx)) {
// Prime children and recurse
m->rowCount(idx); // trigger child count fetch
settle(loop, 200);
const auto found = findNodeInTree(m, idx, address, depth - 1, loop);
if (found.isValid())
return found;
}
}
return {};
}
// --- SceneGraphTools ---
SceneGraphTools::SceneGraphTools(GammaRaySession *session, QObject *parent)
: QObject(parent)
, m_session(session)
@@ -113,13 +209,10 @@ QString SceneGraphTools::ensureSession() const
if (!m_session)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no session"))).toJson(QJsonDocument::Compact));
// First, opportunistically bring up a connection if we have a URL.
if (m_session->lastUrl().isValid()) {
if (m_session->lastUrl().isValid())
m_session->ensureConnected(6000);
}
if (m_session->isReady())
return {};
// No URL known OR reconnect failed: tell the user to call connectProbe.
QJsonObject err = errorJson(
m_session->lastUrl().isValid()
? QStringLiteral("not connected to probe (last error: %1; call connectProbe to retry)")
@@ -129,6 +222,8 @@ QString SceneGraphTools::ensureSession() const
return QString::fromUtf8(QJsonDocument(err).toJson(QJsonDocument::Compact));
}
// --- Connection management ---
QString SceneGraphTools::connectProbe(const QString &host, int port) const
{
if (!m_session)
@@ -181,6 +276,8 @@ QString SceneGraphTools::probeStatus() const
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
}
// --- Window-level navigation ---
QString SceneGraphTools::listQuickWindows() const
{
if (const QString e = ensureSession(); !e.isEmpty())
@@ -210,19 +307,11 @@ QString SceneGraphTools::selectQuickWindow(int index) const
if (const QString e = ensureSession(); !e.isEmpty())
return e;
// The QuickInspectorInterface lives on the probe side. Its header isn't in
// the installed client headers, but Endpoint::invokeObject() can forward a
// method call to a remote object by name+address. The name→address mapping
// is synced during the handshake (ready()), so once connected we can call
// it directly — no client-side stub needed (invokeObject only uses the
// address, not a local object). The object name is the interface IID
// (quickinspectorinterface.cpp:59 registers via the IID), and the method
// name is the bare slot name "selectWindow" (matches QuickInspectorClient).
auto *ep = GammaRay::Endpoint::instance();
if (!ep)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no GammaRay endpoint"))).toJson(QJsonDocument::Compact));
ep->invokeObject(QStringLiteral("com.kdab.GammaRay.QuickInspectorInterface/1.0"),
ep->invokeObject(QString::fromUtf8(kQuickInspectorIface),
"selectWindow", QVariantList{index});
return QString::fromUtf8(QJsonDocument(QJsonObject{
{ QStringLiteral("status"), QStringLiteral("selected") },
@@ -240,15 +329,493 @@ QString SceneGraphTools::listScenegraphNodes() const
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("QuickSceneGraphModel not available (no Quick app attached?)"))).toJson(QJsonDocument::Compact));
// Prime + settle across a few rounds so lazily-fetched cell data and child
// row counts have time to arrive before we snapshot the tree.
QEventLoop loop;
for (int round = 0; round < 4; ++round) {
primeTree(m, QModelIndex(), 16);
settle(loop, 600);
}
// Signal-driven tree loading: prime + settle until the model goes quiet
// and no cells are "Loading...". More rounds + longer settle for reliability.
primeAndWait(m, 6, 300, 500);
// Depth cap to keep responses bounded for large scenes.
const QJsonArray tree = walkChildren(m, QModelIndex(), 16);
return QString::fromUtf8(QJsonDocument(tree).toJson(QJsonDocument::Compact));
}
// --- SG node selection ---
// TODO: all code below is disabled until the selection-sync / sub-model-population
// bug is fixed. See PLAN.md "Known issues / Blocked" section.
#if 0
QModelIndex SceneGraphTools::findNodeByAddress(QAbstractItemModel *m, const QString &address) const
{
if (!m || address.isEmpty())
return {};
// Signal-driven tree loading to maximize the chance of finding the node.
primeAndWait(m, 6, 300, 500);
QEventLoop loop;
// Now search for the address.
const auto idx = findNodeInTree(m, QModelIndex(), address, 16, loop);
if (idx.isValid())
return idx;
// Maybe the tree was still loading — try a couple more rounds.
for (int round = 0; round < 3; ++round) {
primeAndWait(m, 2, 300, 500);
const auto idx2 = findNodeInTree(m, QModelIndex(), address, 16, loop);
if (idx2.isValid())
return idx2;
}
return {};
}
void SceneGraphTools::waitForRows(QAbstractItemModel *m, int timeoutMs) const
{
if (!m)
return;
QEventLoop loop;
int waited = 0;
const int step = 150;
while (waited < timeoutMs) {
if (m->rowCount() > 0)
return;
QTimer::singleShot(step, &loop, &QEventLoop::quit);
loop.exec();
waited += step;
}
}
QString SceneGraphTools::ensureNodeSelected(const QString &address) const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
if (address.isEmpty())
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("address is required"))).toJson(QJsonDocument::Compact));
// If already selected, nothing to do.
if (address == m_selectedAddress)
return {};
auto *sgModel = m_session->model(QString::fromUtf8(kQuickSceneGraphModel));
if (!sgModel)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("QuickSceneGraphModel not available"))).toJson(QJsonDocument::Compact));
const QModelIndex idx = findNodeByAddress(sgModel, address);
if (!idx.isValid())
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("node %1 not found in scene graph tree (still loading?)").arg(address)))
.toJson(QJsonDocument::Compact));
auto *selModel = m_session->selectionModel(sgModel);
if (!selModel)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("selection model not available"))).toJson(QJsonDocument::Compact));
// CRITICAL: Create sub-model RemoteModels BEFORE sending the selection.
// The probe populates and resets sub-models (vertex, adjacency, shader,
// materialProperty) when it receives the selection. The RemoteModelServer
// only forwards modelReset/rowsInserted to clients that are monitoring.
// By creating the RemoteModels first, their ObjectMonitored messages reach
// the server before the SelectionModelSelect (FIFO on same TCP socket),
// so the server is already monitoring when the reset fires.
const QString base = QString::fromUtf8(kBaseName);
auto *vModel = m_session->model(base + ".sgGeometryVertexModel");
auto *aModel = m_session->model(base + ".sgGeometryAdjacencyModel");
auto *sModel = m_session->model(base + ".shaderModel");
auto *mModel = m_session->model(base + ".materialPropertyModel");
// Turn off slow mode before selecting to prevent SG tree updates from
// interfering with the selection (continuous rendering causes model resets
// that can clear the selection).
GammaRay::Endpoint::instance()->invokeObject(
QString::fromUtf8(kQuickInspectorIface),
"setSlowMode", QVariantList{ false });
// Wait long enough for:
// 1. ObjectMonitored messages to reach the server and start monitoring
// 2. SelectionModelStateRequest (125ms timer from SelectionModelClient ctor)
// to be sent and the server's response (default selection) to arrive
// 3. Any SG model updates from setSlowMode(false) to settle
QEventLoop loop;
settle(loop, 2000);
// Now send the selection. The server should now be in a stable state with
// the default selection already processed. Our selection will override it.
selModel->select(idx, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows | QItemSelectionModel::Current);
m_selectedAddress = address;
// Also manually construct and send a SelectionModelSelect message directly
// to the server's selection model address, bypassing SelectionModelClient.
// This eliminates any issues with SelectionModelClient state.
{
const auto selAddr = GammaRay::Endpoint::instance()->objectAddress(
QStringLiteral("%1.selection").arg(QString::fromUtf8(kQuickSceneGraphModel)));
if (selAddr != GammaRay::Protocol::InvalidObjectAddress) {
// Check the path length
const auto mi = GammaRay::Protocol::fromQModelIndex(idx);
int pathLen = mi.size();
// Log the path for debugging
QString pathStr;
for (int i = 0; i < mi.size(); ++i)
pathStr += QStringLiteral("(%1,%2) ").arg(mi[i].row).arg(mi[i].column);
GammaRay::Message msg(selAddr, GammaRay::Protocol::SelectionModelSelect);
msg << qint32(1); // 1 range
msg << mi << mi; // topLeft, bottomRight
msg << (QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows | QItemSelectionModel::Current);
GammaRay::Endpoint::send(msg);
// Store path info for diagnostics
m_lastSelectPath = QStringLiteral("len=%1 %2").arg(pathLen).arg(pathStr);
}
}
// Wait for the selection to be processed and sub-model data to arrive.
settle(loop, 2000);
// Re-send via direct message to ensure it sticks.
{
const auto selAddr = GammaRay::Endpoint::instance()->objectAddress(
QStringLiteral("%1.selection").arg(QString::fromUtf8(kQuickSceneGraphModel)));
if (selAddr != GammaRay::Protocol::InvalidObjectAddress) {
GammaRay::Message msg(selAddr, GammaRay::Protocol::SelectionModelSelect);
const auto mi = GammaRay::Protocol::fromQModelIndex(idx);
msg << qint32(1);
msg << mi << mi;
msg << (QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows | QItemSelectionModel::Current);
GammaRay::Endpoint::send(msg);
}
}
settle(loop, 2000);
// Prime sub-models to fetch their row/column counts and cell data.
if (vModel) { vModel->rowCount(); primeAndWait(vModel, 4, 300, 500, 1500); }
if (aModel) { aModel->rowCount(); primeAndWait(aModel, 4, 300, 500, 1500); }
if (sModel) { sModel->rowCount(); primeAndWait(sModel, 4, 300, 500, 1500); }
if (mModel) { mModel->rowCount(); primeAndWait(mModel, 4, 300, 500, 1500); }
return {};
}
QString SceneGraphTools::selectScenegraphNode(const QString &address) const
{
m_selectedAddress.clear(); // force reselect
const QString e = ensureNodeSelected(address);
if (!e.isEmpty())
return e;
// Report what we know about the selected node.
auto *sgModel = m_session->model(QString::fromUtf8(kQuickSceneGraphModel));
const QModelIndex idx = findNodeByAddress(sgModel, address);
QString type;
if (idx.isValid()) {
const QModelIndex typeIdx = sgModel->index(idx.row(), 1, idx.parent());
type = sgModel->data(typeIdx, Qt::DisplayRole).toString();
}
// Check selection state and sub-model row counts
auto *sgModel2 = m_session->model(QString::fromUtf8(kQuickSceneGraphModel));
auto *selModel = m_session->selectionModel(sgModel2);
auto *vModel = m_session->model(QStringLiteral("%1.sgGeometryVertexModel").arg(QString::fromUtf8(kBaseName)));
auto *aModel = m_session->model(QStringLiteral("%1.sgGeometryAdjacencyModel").arg(QString::fromUtf8(kBaseName)));
auto *sModel = m_session->model(QStringLiteral("%1.shaderModel").arg(QString::fromUtf8(kBaseName)));
auto *mModel = m_session->model(QStringLiteral("%1.materialPropertyModel").arg(QString::fromUtf8(kBaseName)));
// Diagnostic: check if models are registered on the probe (have valid addresses)
auto *ep = GammaRay::Endpoint::instance();
const auto vAddr = ep ? ep->objectAddress(QStringLiteral("%1.sgGeometryVertexModel").arg(QString::fromUtf8(kBaseName))) : 0;
const auto aAddr = ep ? ep->objectAddress(QStringLiteral("%1.sgGeometryAdjacencyModel").arg(QString::fromUtf8(kBaseName))) : 0;
const auto sAddr = ep ? ep->objectAddress(QStringLiteral("%1.shaderModel").arg(QString::fromUtf8(kBaseName))) : 0;
const auto mAddr = ep ? ep->objectAddress(QStringLiteral("%1.materialPropertyModel").arg(QString::fromUtf8(kBaseName))) : 0;
const auto selAddr = ep ? ep->objectAddress(QStringLiteral("%1.selection").arg(QString::fromUtf8(kQuickSceneGraphModel))) : 0;
// Also check the wrong name (in case source model name mismatch)
const auto dotSelAddr = ep ? ep->objectAddress(QStringLiteral(".selection")) : 0;
// Check what the selected index looks like
QString selIdxInfo;
if (selModel && selModel->hasSelection()) {
auto rows = selModel->selectedRows();
if (rows.isEmpty()) {
selIdxInfo = QStringLiteral("hasSelection=true but selectedRows() empty");
} else {
// Walk the parent chain to get the full path
QStringList pathParts;
QModelIndex cur = rows.first();
while (cur.isValid()) {
QString addr = cur.data(Qt::DisplayRole).toString();
pathParts.prepend(QStringLiteral("r%1:%2").arg(cur.row()).arg(addr));
cur = cur.parent();
}
selIdxInfo = pathParts.join(" -> ");
}
} else {
selIdxInfo = QStringLiteral("no selection");
}
QJsonObject out = {
{ QStringLiteral("address"), address },
{ QStringLiteral("type"), type },
{ QStringLiteral("hasGeometry"), vModel && vModel->rowCount() > 0 },
{ QStringLiteral("hasMaterial"), sModel && sModel->rowCount() > 0 },
{ QStringLiteral("_diag_epConnected"), ep ? ep->isConnected() : false },
{ QStringLiteral("_diag_selModelAddr"), qint64(selAddr) },
{ QStringLiteral("_diag_selHasSelection"), selModel ? selModel->hasSelection() : false },
{ QStringLiteral("_diag_selIdxInfo"), selIdxInfo },
{ QStringLiteral("_diag_vertexModelAddr"), qint64(vAddr) },
{ QStringLiteral("_diag_adjacencyModelAddr"), qint64(aAddr) },
{ QStringLiteral("_diag_shaderModelAddr"), qint64(sAddr) },
{ QStringLiteral("_diag_materialPropModelAddr"), qint64(mAddr) },
{ QStringLiteral("_diag_vertexRowCount"), vModel ? vModel->rowCount() : -1 },
{ QStringLiteral("_diag_adjacencyRowCount"), aModel ? aModel->rowCount() : -1 },
{ QStringLiteral("_diag_shaderRowCount"), sModel ? sModel->rowCount() : -1 },
{ QStringLiteral("_diag_materialPropRowCount"), mModel ? mModel->rowCount() : -1 },
{ QStringLiteral("_diag_vertexColCount"), vModel ? vModel->columnCount() : -1 },
{ QStringLiteral("_diag_vertexData00"), vModel && vModel->rowCount() > 0 && vModel->columnCount() > 0 ? vModel->data(vModel->index(0, 0)).toString() : QString() },
};
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
}
// --- Geometry ---
QString SceneGraphTools::getNodeVertices(const QString &address) const
{
const QString e = ensureNodeSelected(address);
if (!e.isEmpty())
return e;
auto *m = m_session->model(QStringLiteral("%1.sgGeometryVertexModel").arg(QString::fromUtf8(kBaseName)));
if (!m)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("sgGeometryVertexModel not available"))).toJson(QJsonDocument::Compact));
waitForRows(m, 3000);
const int rows = m->rowCount();
const int cols = m->columnCount();
if (rows == 0)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no vertex data (selected node may not be a GeometryNode)")))
.toJson(QJsonDocument::Compact));
QJsonArray headers;
QJsonArray isCoordinate;
for (int c = 0; c < cols; ++c) {
headers.append(m->headerData(c, Qt::Horizontal, Qt::DisplayRole).toString());
isCoordinate.append(m->data(m->index(0, c), kIsCoordinateRole).toBool());
}
QJsonArray vertices;
for (int r = 0; r < rows; ++r) {
QJsonArray row;
for (int c = 0; c < cols; ++c)
row.append(m->data(m->index(r, c), Qt::DisplayRole).toString());
vertices.append(row);
}
QJsonObject out = {
{ QStringLiteral("vertexCount"), rows },
{ QStringLiteral("attributes"), headers },
{ QStringLiteral("isCoordinate"), isCoordinate },
{ QStringLiteral("vertices"), vertices },
};
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
}
QString SceneGraphTools::getNodeAdjacency(const QString &address) const
{
const QString e = ensureNodeSelected(address);
if (!e.isEmpty())
return e;
auto *m = m_session->model(QStringLiteral("%1.sgGeometryAdjacencyModel").arg(QString::fromUtf8(kBaseName)));
if (!m)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("sgGeometryAdjacencyModel not available"))).toJson(QJsonDocument::Compact));
waitForRows(m, 3000);
const int rows = m->rowCount();
if (rows == 0)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no adjacency data (selected node may not be a GeometryNode)")))
.toJson(QJsonDocument::Compact));
// DrawingModeRole is the same for all rows — read from row 0.
int drawingMode = m->data(m->index(0, 0), kDrawingModeRole).toInt();
QJsonArray indices;
for (int r = 0; r < rows; ++r)
indices.append(m->data(m->index(r, 0), kRenderRole).toInt());
QJsonObject out = {
{ QStringLiteral("drawingMode"), drawingMode },
{ QStringLiteral("indexCount"), rows },
{ QStringLiteral("indices"), indices },
};
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
}
// --- Material/Shader ---
QString SceneGraphTools::getMaterialShaders(const QString &address) const
{
const QString e = ensureNodeSelected(address);
if (!e.isEmpty())
return e;
auto *m = m_session->model(QStringLiteral("%1.shaderModel").arg(QString::fromUtf8(kBaseName)));
if (!m)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("shaderModel not available"))).toJson(QJsonDocument::Compact));
waitForRows(m, 3000);
const int rows = m->rowCount();
if (rows == 0)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no shaders (selected node may not have a material)")))
.toJson(QJsonDocument::Compact));
QJsonArray out;
for (int r = 0; r < rows; ++r) {
const QString stage = m->data(m->index(r, 0), Qt::DisplayRole).toString();
out.append(QJsonObject{
{ QStringLiteral("row"), r },
{ QStringLiteral("stage"), stage },
});
}
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
}
QString SceneGraphTools::getShaderSource(int row) const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
// Get or create the MaterialExtensionInterface proxy. The factory (registered
// in initOnce) creates a MaterialExtensionProxy that self-registers via
// ObjectBroker::registerObject. The probe forwards gotShader emissions to it.
auto *proxy = qobject_cast<GammaRay::MaterialExtensionInterface *>(
GammaRay::ObjectBroker::objectInternal(
QStringLiteral("%1.material").arg(QString::fromUtf8(kBaseName)),
QByteArray(kMaterialIface)));
if (!proxy)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("MaterialExtensionInterface proxy not available"))).toJson(QJsonDocument::Compact));
QString result;
QEventLoop loop;
const auto conn = QObject::connect(proxy, &GammaRay::MaterialExtensionInterface::gotShader,
&loop, [&](const QString &src) {
result = src;
loop.quit();
});
QTimer::singleShot(10000, &loop, &QEventLoop::quit);
proxy->getShader(row); // forwards via invokeObject to the probe
loop.exec();
QObject::disconnect(conn);
if (result.isEmpty())
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("timeout waiting for shader source (row %1)").arg(row)))
.toJson(QJsonDocument::Compact));
QJsonObject out = {
{ QStringLiteral("row"), row },
{ QStringLiteral("source"), result },
};
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
}
QString SceneGraphTools::getMaterialProperties(const QString &address) const
{
const QString e = ensureNodeSelected(address);
if (!e.isEmpty())
return e;
auto *m = m_session->model(QStringLiteral("%1.materialPropertyModel").arg(QString::fromUtf8(kBaseName)));
if (!m)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("materialPropertyModel not available"))).toJson(QJsonDocument::Compact));
waitForRows(m, 3000);
const int rows = m->rowCount();
const int cols = m->columnCount();
if (rows == 0)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no material properties (selected node may not have a material)")))
.toJson(QJsonDocument::Compact));
QJsonArray out;
for (int r = 0; r < rows; ++r) {
QJsonObject prop;
for (int c = 0; c < cols; ++c) {
const QString header = m->headerData(c, Qt::Horizontal, Qt::DisplayRole).toString();
const QString val = m->data(m->index(r, c), Qt::DisplayRole).toString();
prop.insert(header.isEmpty() ? QString::number(c) : header, val);
}
out.append(prop);
}
return QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Compact));
}
#endif // disabled until selection-sync bug is fixed
// --- Rendering visualization ---
QString SceneGraphTools::setRenderMode(const QString &mode) const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
auto *ep = GammaRay::Endpoint::instance();
if (!ep)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no GammaRay endpoint"))).toJson(QJsonDocument::Compact));
// Parse mode string → enum. Accept full enum name or short alias.
GammaRay::QuickInspectorInterface::RenderMode rm = GammaRay::QuickInspectorInterface::NormalRendering;
static const QHash<QString, GammaRay::QuickInspectorInterface::RenderMode> map = {
{ QStringLiteral("NormalRendering"), GammaRay::QuickInspectorInterface::NormalRendering },
{ QStringLiteral("normal"), GammaRay::QuickInspectorInterface::NormalRendering },
{ QStringLiteral("VisualizeClipping"), GammaRay::QuickInspectorInterface::VisualizeClipping },
{ QStringLiteral("clipping"), GammaRay::QuickInspectorInterface::VisualizeClipping },
{ QStringLiteral("VisualizeOverdraw"), GammaRay::QuickInspectorInterface::VisualizeOverdraw },
{ QStringLiteral("overdraw"), GammaRay::QuickInspectorInterface::VisualizeOverdraw },
{ QStringLiteral("VisualizeBatches"), GammaRay::QuickInspectorInterface::VisualizeBatches },
{ QStringLiteral("batches"), GammaRay::QuickInspectorInterface::VisualizeBatches },
{ QStringLiteral("VisualizeChanges"), GammaRay::QuickInspectorInterface::VisualizeChanges },
{ QStringLiteral("changes"), GammaRay::QuickInspectorInterface::VisualizeChanges },
{ QStringLiteral("VisualizeTraces"), GammaRay::QuickInspectorInterface::VisualizeTraces },
{ QStringLiteral("traces"), GammaRay::QuickInspectorInterface::VisualizeTraces },
};
if (map.contains(mode))
rm = map.value(mode);
else
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("unknown render mode '%1' — valid: NormalRendering, VisualizeClipping, VisualizeOverdraw, VisualizeBatches, VisualizeChanges, VisualizeTraces").arg(mode)))
.toJson(QJsonDocument::Compact));
ep->invokeObject(QString::fromUtf8(kQuickInspectorIface),
"setCustomRenderMode",
QVariantList{QVariant::fromValue(rm)});
return QString::fromUtf8(QJsonDocument(QJsonObject{
{ QStringLiteral("renderMode"), mode },
}).toJson(QJsonDocument::Compact));
}
QString SceneGraphTools::setSlowMode(bool enabled) const
{
if (const QString e = ensureSession(); !e.isEmpty())
return e;
auto *ep = GammaRay::Endpoint::instance();
if (!ep)
return QString::fromUtf8(QJsonDocument(errorJson(
QStringLiteral("no GammaRay endpoint"))).toJson(QJsonDocument::Compact));
ep->invokeObject(QString::fromUtf8(kQuickInspectorIface),
"setSlowMode", QVariantList{enabled});
return QString::fromUtf8(QJsonDocument(QJsonObject{
{ QStringLiteral("slowMode"), enabled },
}).toJson(QJsonDocument::Compact));
}

View File

@@ -15,6 +15,17 @@
established (via --connect at startup OR a prior connectProbe() call), a stale
session is transparently reconnected. If no URL is known, the tool returns a
JSON error pointing the user at connectProbe().
SG node selection model
-----------------------
Geometry/material/texture sub-models are populated by the probe-side
PropertyController only for the *currently selected* SG node. Selection is
driven via ObjectBroker::selectionModel(sgModel) which returns a synced
SelectionModelClient. Calling select(index, ClearAndSelect) on it sends the
selection to the probe, which calls setObject() on all PropertyController
extensions, which in turn populate sgGeometryVertexModel, shaderModel, etc.
Tools that need per-node data call ensureNodeSelected(address) first, which
finds the node index in the (lazily-fetched) SG tree and selects it.
*/
#ifndef SCENEGRAPH_TOOLS_H
@@ -22,6 +33,7 @@
#include <QObject>
#include <QString>
#include <QModelIndex>
class GammaRaySession;
@@ -32,43 +44,66 @@ public:
explicit SceneGraphTools(GammaRaySession *session, QObject *parent = nullptr);
// --- Connection management ---
// Connect to a GammaRay probe. host defaults to 127.0.0.1, port to 11732
// (GammaRay's default). Blocks up to ~6s for the handshake. Returns JSON:
// {"connected":bool,"state":"ready|connecting|failed|disconnected",
// "url":"tcp://127.0.0.1:11732","error":"<only if failed>"}
// Even on failure the URL is remembered so subsequent tool calls will retry.
Q_INVOKABLE QString connectProbe(const QString &host, int port) const;
// Same with default port (overload not supported by qtmcp; use 0 to mean default).
Q_INVOKABLE QString connectProbeDefault() const;
// Drop the current connection and forget the URL. Subsequent tool calls
// will require connectProbe() again.
Q_INVOKABLE QString disconnectProbe() const;
// Report current connection state. Returns the same JSON shape as
// connectProbe() minus the action.
Q_INVOKABLE QString probeStatus() const;
// --- Introspection ---
// List top-level QQuickWindows. Returns compact JSON: [{"address":..,"type":..}]
// --- Window-level navigation ---
Q_INVOKABLE QString listQuickWindows() const;
// Select a Quick window by index (into listQuickWindows) on the probe side.
// This is required before listScenegraphNodes returns anything — the
// QuickSceneGraphModel is built for the *selected* window.
// Uses Endpoint::invokeObject directly since QuickInspectorInterface is not
// in the installed client headers (PLAN.md open question #1).
Q_INVOKABLE QString selectQuickWindow(int index) const;
// Walk the QSGNode tree and return nested JSON:
// [{"address":..,"type":..,"children":[...]}]
// --- SceneGraph navigation ---
Q_INVOKABLE QString listScenegraphNodes() const;
// TODO: re-enable when the selection-sync / sub-model-population bug is fixed
// Find a node by address (e.g. "0x56396e6d7400") in the SG tree and select it
// via the synced selection model. Returns JSON with the node's type and which
// sub-models have data. Must be called before geometry/material tools can
// return data for that node. Selecting a non-GeometryNode clears the
// geometry/material models (they only populate for QSGGeometryNode).
// Q_INVOKABLE QString selectScenegraphNode(const QString &address) const;
// --- Geometry (requires a GeometryNode to be selected) ---
// TODO: re-enable when the selection-sync / sub-model-population bug is fixed
// Returns vertex data: rows = vertices, columns = attributes.
// Each cell is the display string (e.g. "0.5, 0.5, 0, 1"); headers are
// attribute type names (PositionAttribute, ColorAttribute, ...).
// Also includes isCoordinate per column.
// Q_INVOKABLE QString getNodeVertices(const QString &address) const;
// Returns adjacency data: drawing mode + index list.
// Q_INVOKABLE QString getNodeAdjacency(const QString &address) const;
// --- Material/Shader (requires a GeometryNode to be selected) ---
// TODO: re-enable when the selection-sync / sub-model-population bug is fixed
// List shader stages (Vertex, Fragment, ...) for the selected node's material.
// Q_INVOKABLE QString getMaterialShaders(const QString &address) const;
// Async: request shader source for a row (from getMaterialShaders).
// Uses the MaterialExtensionInterface proxy + gotShader signal.
// Q_INVOKABLE QString getShaderSource(int row) const;
// Material properties (name/value pairs from AggregatedPropertyModel).
// Q_INVOKABLE QString getMaterialProperties(const QString &address) const;
// --- Rendering visualization ---
// Set custom render mode. mode ∈ {NormalRendering, VisualizeClipping,
// VisualizeOverdraw, VisualizeBatches, VisualizeChanges, VisualizeTraces}
Q_INVOKABLE QString setRenderMode(const QString &mode) const;
// Toggle slow animations mode (renders continuously instead of on-demand).
Q_INVOKABLE QString setSlowMode(bool enabled) const;
private:
GammaRaySession *m_session;
mutable QString m_selectedAddress; // last selected node address (for sub-model tools)
mutable QString m_lastSelectPath; // diagnostic: last selection path info
// Shared helper: ensures a connection (auto-reconnect if a URL is known),
// returns nullptr on success or a JSON error string the caller can return
// verbatim. The JSON always hints at connectProbe() when no URL is known.
QString ensureSession() const;
// Find the QModelIndex for address in the SG tree (prime + search).
// Returns invalid index if not found.
QModelIndex findNodeByAddress(QAbstractItemModel *m, const QString &address) const;
// Ensure the given SG node is selected (select it if not already). Returns
// empty string on success, or a JSON error string.
QString ensureNodeSelected(const QString &address) const;
// Wait for a model to have rows (polling with event loop, up to timeoutMs).
void waitForRows(QAbstractItemModel *m, int timeoutMs) const;
};
#endif // SCENEGRAPH_TOOLS_H