pycode-kg 0.16.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycode_kg/.DS_Store +0 -0
- pycode_kg/__init__.py +91 -0
- pycode_kg/__main__.py +11 -0
- pycode_kg/analysis/__init__.py +15 -0
- pycode_kg/analysis/bridge.py +108 -0
- pycode_kg/analysis/centrality.py +412 -0
- pycode_kg/analysis/framework_detector.py +103 -0
- pycode_kg/analysis/hybrid_rank.py +53 -0
- pycode_kg/app.py +1335 -0
- pycode_kg/architecture.py +624 -0
- pycode_kg/build_pycodekg_lancedb.py +15 -0
- pycode_kg/build_pycodekg_sqlite.py +15 -0
- pycode_kg/cli/__init__.py +29 -0
- pycode_kg/cli/cmd_analyze.py +96 -0
- pycode_kg/cli/cmd_architecture.py +150 -0
- pycode_kg/cli/cmd_bridges.py +26 -0
- pycode_kg/cli/cmd_build.py +154 -0
- pycode_kg/cli/cmd_build_full.py +242 -0
- pycode_kg/cli/cmd_centrality.py +131 -0
- pycode_kg/cli/cmd_explain.py +180 -0
- pycode_kg/cli/cmd_framework_nodes.py +18 -0
- pycode_kg/cli/cmd_hooks.py +137 -0
- pycode_kg/cli/cmd_init.py +312 -0
- pycode_kg/cli/cmd_mcp.py +71 -0
- pycode_kg/cli/cmd_model.py +53 -0
- pycode_kg/cli/cmd_query.py +211 -0
- pycode_kg/cli/cmd_snapshot.py +421 -0
- pycode_kg/cli/cmd_viz.py +180 -0
- pycode_kg/cli/main.py +23 -0
- pycode_kg/cli/options.py +63 -0
- pycode_kg/config.py +78 -0
- pycode_kg/graph.py +125 -0
- pycode_kg/index.py +542 -0
- pycode_kg/kg.py +220 -0
- pycode_kg/layout3d.py +470 -0
- pycode_kg/mcp/bridge_tools.py +19 -0
- pycode_kg/mcp/framework_tools.py +18 -0
- pycode_kg/mcp_server.py +1965 -0
- pycode_kg/module/__init__.py +83 -0
- pycode_kg/module/base.py +720 -0
- pycode_kg/module/extractor.py +276 -0
- pycode_kg/module/types.py +532 -0
- pycode_kg/pycodekg.py +543 -0
- pycode_kg/pycodekg_query.py +1 -0
- pycode_kg/pycodekg_snippet_packer.py +1 -0
- pycode_kg/pycodekg_thorough_analysis.py +2751 -0
- pycode_kg/pycodekg_viz.py +1 -0
- pycode_kg/pycodekg_viz3d.py +1 -0
- pycode_kg/ranking/__init__.py +1 -0
- pycode_kg/ranking/cli_rank.py +92 -0
- pycode_kg/ranking/coderank.py +555 -0
- pycode_kg/snapshots.py +612 -0
- pycode_kg/sql/004_add_centrality_table.sql +12 -0
- pycode_kg/store.py +766 -0
- pycode_kg/utils.py +39 -0
- pycode_kg/visitor.py +413 -0
- pycode_kg/viz3d.py +1353 -0
- pycode_kg/viz3d_timeline.py +364 -0
- pycode_kg-0.16.0.dist-info/METADATA +305 -0
- pycode_kg-0.16.0.dist-info/RECORD +63 -0
- pycode_kg-0.16.0.dist-info/WHEEL +4 -0
- pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
- pycode_kg-0.16.0.dist-info/licenses/LICENSE +94 -0
pycode_kg/viz3d.py
ADDED
|
@@ -0,0 +1,1353 @@
|
|
|
1
|
+
"""
|
|
2
|
+
viz3d.py — PyVista/PyQt5 3-D knowledge-graph visualiser for PyCodeKG.
|
|
3
|
+
|
|
4
|
+
Adapted from *repo_vis/pkg_visualizer/pkg_visualizer.py*
|
|
5
|
+
(Eric G. Suchanek, PhD — https://github.com/suchanek/repo_vis).
|
|
6
|
+
|
|
7
|
+
Window layout mirrors repo_vis exactly:
|
|
8
|
+
Left — control panel (DB path, layout selector, module filter, render options)
|
|
9
|
+
Right — PyVista QtInteractor + button row (Reset View, Reset Settings, status)
|
|
10
|
+
|
|
11
|
+
Pick any node to open a modeless docstring popup; the picked node is
|
|
12
|
+
highlighted in pink and the camera zooms in, exactly as in repo_vis.
|
|
13
|
+
|
|
14
|
+
Author: Eric G. Suchanek, PhD
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# pylint: disable=C0301,C0116,C0115,W0613,E0611,C0415
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import atexit
|
|
22
|
+
import gc
|
|
23
|
+
import logging
|
|
24
|
+
import re
|
|
25
|
+
import sys
|
|
26
|
+
import warnings
|
|
27
|
+
from collections import Counter
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
import param
|
|
32
|
+
import pyvista as pv
|
|
33
|
+
from markdown import markdown # type: ignore[import-untyped]
|
|
34
|
+
from PyQt5.QtCore import Qt, pyqtSignal
|
|
35
|
+
from PyQt5.QtGui import QFont
|
|
36
|
+
from PyQt5.QtWidgets import (
|
|
37
|
+
QApplication,
|
|
38
|
+
QCheckBox,
|
|
39
|
+
QComboBox,
|
|
40
|
+
QDialog,
|
|
41
|
+
QHBoxLayout,
|
|
42
|
+
QLabel,
|
|
43
|
+
QLineEdit,
|
|
44
|
+
QListWidget,
|
|
45
|
+
QMainWindow,
|
|
46
|
+
QPushButton,
|
|
47
|
+
QSizePolicy,
|
|
48
|
+
QTextBrowser,
|
|
49
|
+
QVBoxLayout,
|
|
50
|
+
QWidget,
|
|
51
|
+
)
|
|
52
|
+
from pyvistaqt import QtInteractor
|
|
53
|
+
from rich.logging import RichHandler
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Logging
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
logging.basicConfig(level=logging.WARNING, handlers=[RichHandler()])
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Constants
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
__version__ = "0.1.0"
|
|
67
|
+
__author__ = "Eric G. Suchanek, PhD"
|
|
68
|
+
|
|
69
|
+
DEFAULT_DB = ".pycodekg/graph.sqlite"
|
|
70
|
+
DEFAULT_SAVE = "pycodekg_3d"
|
|
71
|
+
|
|
72
|
+
CONTROL_PANEL_WIDTH: int = 240
|
|
73
|
+
BUTTON_WIDTH: int = 120
|
|
74
|
+
ZOOM_FACTOR: float = 10.0
|
|
75
|
+
|
|
76
|
+
# Node colours (aligned with app.py pyvis visualiser)
|
|
77
|
+
KIND_COLOR: dict[str, str] = {
|
|
78
|
+
"module": "#4A90D9",
|
|
79
|
+
"class": "#27AE60",
|
|
80
|
+
"function": "#E74C3C",
|
|
81
|
+
"method": "#3498DB",
|
|
82
|
+
"symbol": "#95A5A6",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Node sizes (radius)
|
|
86
|
+
KIND_SIZE: dict[str, float] = {
|
|
87
|
+
"module": 1.2,
|
|
88
|
+
"class": 0.9,
|
|
89
|
+
"function": 0.7,
|
|
90
|
+
"method": 0.5,
|
|
91
|
+
"symbol": 0.4,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Edge colours
|
|
95
|
+
REL_COLOR: dict[str, str] = {
|
|
96
|
+
"CONTAINS": "#555555",
|
|
97
|
+
"CALLS": "#E74C3C",
|
|
98
|
+
"IMPORTS": "#3498DB",
|
|
99
|
+
"INHERITS": "#F39C12",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# LOD thresholds (total visible nodes)
|
|
103
|
+
LOD_HIGH: int = 400 # icospheres / cylinders
|
|
104
|
+
LOD_LOW: int = 1500 # cubes; above → small spheres
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Internal geometry helpers
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _make_node_mesh(kind: str, center: np.ndarray, size: float, lod: str):
|
|
112
|
+
"""
|
|
113
|
+
Return a PyVista mesh for a single node, adapting geometry to the LOD tier.
|
|
114
|
+
|
|
115
|
+
:param kind: Node kind string (``module``, ``class``, etc.).
|
|
116
|
+
:param center: 3-D centre position.
|
|
117
|
+
:param size: Node radius.
|
|
118
|
+
:param lod: LOD tier — ``"high"``, ``"low"``, or ``"points"``.
|
|
119
|
+
:return: PyVista PolyData mesh.
|
|
120
|
+
"""
|
|
121
|
+
if lod == "high":
|
|
122
|
+
if kind == "module":
|
|
123
|
+
h = size * 0.9
|
|
124
|
+
return pv.Box(
|
|
125
|
+
bounds=(
|
|
126
|
+
center[0] - h,
|
|
127
|
+
center[0] + h,
|
|
128
|
+
center[1] - h,
|
|
129
|
+
center[1] + h,
|
|
130
|
+
center[2] - h,
|
|
131
|
+
center[2] + h,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
elif kind == "function":
|
|
135
|
+
return pv.Cylinder(
|
|
136
|
+
center=center,
|
|
137
|
+
direction=(0, 0, 1),
|
|
138
|
+
radius=size * 0.6,
|
|
139
|
+
height=size * 1.4,
|
|
140
|
+
resolution=12,
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
return pv.Icosahedron(radius=size, center=center)
|
|
144
|
+
elif lod == "low":
|
|
145
|
+
h = size * 0.9
|
|
146
|
+
return pv.Box(
|
|
147
|
+
bounds=(
|
|
148
|
+
center[0] - h,
|
|
149
|
+
center[0] + h,
|
|
150
|
+
center[1] - h,
|
|
151
|
+
center[1] + h,
|
|
152
|
+
center[2] - h,
|
|
153
|
+
center[2] + h,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
return pv.Sphere(radius=size * 0.5, center=center, theta_resolution=4, phi_resolution=4)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _arc_points(p1: np.ndarray, p2: np.ndarray, n_pts: int = 24, lift: float = 0.35) -> np.ndarray:
|
|
161
|
+
"""
|
|
162
|
+
Quadratic Bézier arc from *p1* to *p2*, apex lifted ``lift × chord`` in Z.
|
|
163
|
+
|
|
164
|
+
:param p1: Start point.
|
|
165
|
+
:param p2: End point.
|
|
166
|
+
:param n_pts: Number of sample points.
|
|
167
|
+
:param lift: Fraction of chord length used as Z lift.
|
|
168
|
+
:return: ``(n_pts, 3)`` array.
|
|
169
|
+
"""
|
|
170
|
+
p1, p2 = np.asarray(p1, float), np.asarray(p2, float)
|
|
171
|
+
mid = (p1 + p2) / 2.0
|
|
172
|
+
mid[2] += lift * np.linalg.norm(p2 - p1)
|
|
173
|
+
t = np.linspace(0.0, 1.0, n_pts)[:, None]
|
|
174
|
+
return (1 - t) ** 2 * p1 + 2 * t * (1 - t) * mid + t**2 * p2
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _docstring_to_markdown(docstring: str | None) -> str:
|
|
178
|
+
"""
|
|
179
|
+
Convert a ``:param:``-style docstring to Markdown.
|
|
180
|
+
Adapted from ``utility.format_docstring_to_markdown`` in *repo_vis*.
|
|
181
|
+
|
|
182
|
+
:param docstring: Raw Python docstring, or ``None``.
|
|
183
|
+
:return: Markdown-formatted string.
|
|
184
|
+
"""
|
|
185
|
+
if not docstring:
|
|
186
|
+
return "No docstring available."
|
|
187
|
+
lines = docstring.strip().split("\n")
|
|
188
|
+
md: list[str] = [f"# {lines[0]}"]
|
|
189
|
+
for line in lines[1:]:
|
|
190
|
+
line = line.strip()
|
|
191
|
+
if line.startswith(":type") or line.startswith(":rtype"):
|
|
192
|
+
continue
|
|
193
|
+
m = re.match(r":param (\w+): (.+)", line)
|
|
194
|
+
if m:
|
|
195
|
+
md.append(f"- **{m.group(1)}**: {m.group(2)}")
|
|
196
|
+
elif line.startswith(":return:"):
|
|
197
|
+
md.append(line.replace(":return:", "**Returns:**"))
|
|
198
|
+
else:
|
|
199
|
+
md.append(line)
|
|
200
|
+
return "\n".join(md)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _remove_highlight_actors(plotter: pv.Plotter) -> None:
|
|
204
|
+
"""Remove any leftover pink highlight or outline actors from the plotter."""
|
|
205
|
+
for name in list(plotter.actors.keys()):
|
|
206
|
+
if "highlight" in name.lower() or "bounds" in name.lower() or "outline" in name.lower():
|
|
207
|
+
plotter.remove_actor(name, reset_camera=False) # type: ignore[arg-type]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# DocstringPopup — copied from repo_vis (with attribution)
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class DocstringPopup(QDialog):
|
|
216
|
+
"""
|
|
217
|
+
Modeless popup dialog that renders a docstring as HTML Markdown.
|
|
218
|
+
Adapted from *repo_vis/pkg_visualizer/pkg_visualizer.py*
|
|
219
|
+
(Eric G. Suchanek, PhD).
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def __init__(self, title: str, docstring: str, parent=None, on_close_callback=None):
|
|
223
|
+
"""
|
|
224
|
+
Initialise the popup window.
|
|
225
|
+
|
|
226
|
+
:param title: Window title.
|
|
227
|
+
:param docstring: Raw docstring text (will be rendered as Markdown/HTML).
|
|
228
|
+
:param parent: Parent widget.
|
|
229
|
+
:param on_close_callback: Called when the window is closed.
|
|
230
|
+
"""
|
|
231
|
+
super().__init__(parent)
|
|
232
|
+
self.setWindowTitle(title)
|
|
233
|
+
self.setMinimumSize(600, 400)
|
|
234
|
+
self.on_close_callback = on_close_callback
|
|
235
|
+
self.setWindowModality(Qt.NonModal) # type: ignore[attr-defined]
|
|
236
|
+
|
|
237
|
+
if parent:
|
|
238
|
+
geo = parent.screen().geometry()
|
|
239
|
+
self.move(geo.x() + 50, geo.y() + 50)
|
|
240
|
+
|
|
241
|
+
layout = QVBoxLayout(self)
|
|
242
|
+
html = markdown(docstring or "No docstring available.")
|
|
243
|
+
browser = QTextBrowser(self)
|
|
244
|
+
browser.setHtml(html)
|
|
245
|
+
layout.addWidget(browser)
|
|
246
|
+
|
|
247
|
+
close_btn = QPushButton("Close", self)
|
|
248
|
+
close_btn.clicked.connect(self.close) # type: ignore[arg-type]
|
|
249
|
+
layout.addWidget(close_btn)
|
|
250
|
+
|
|
251
|
+
def closeEvent(self, event):
|
|
252
|
+
"""Trigger the close callback if set."""
|
|
253
|
+
if self.on_close_callback:
|
|
254
|
+
self.on_close_callback()
|
|
255
|
+
super().closeEvent(event)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
# create_kg_visualization — adapted from create_allium_visualization()
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def create_kg_visualization(
|
|
264
|
+
viz: KGVisualizer,
|
|
265
|
+
nodes,
|
|
266
|
+
edges,
|
|
267
|
+
plotter: pv.Plotter,
|
|
268
|
+
) -> tuple[pv.Plotter, str, dict[str, dict]]:
|
|
269
|
+
"""
|
|
270
|
+
Render the knowledge graph into *plotter* using the current layout.
|
|
271
|
+
|
|
272
|
+
Mirrors the structure of ``create_allium_visualization`` in *repo_vis*:
|
|
273
|
+
clears the plotter, sets up lights, computes the layout, builds
|
|
274
|
+
per-kind ``pv.MultiBlock`` node meshes, builds per-relation edge
|
|
275
|
+
meshes, and returns the ``actor_to_node`` lookup used by picking.
|
|
276
|
+
|
|
277
|
+
:param viz: :class:`KGVisualizer` instance (source of settings and state).
|
|
278
|
+
:param nodes: List of :class:`~pycode_kg.layout3d.LayoutNode` objects to render.
|
|
279
|
+
:param edges: Full edge list (used for layout computation and edge rendering).
|
|
280
|
+
:param plotter: The ``QtInteractor`` to render into.
|
|
281
|
+
:return: ``(plotter, title_text, actor_to_node)``
|
|
282
|
+
"""
|
|
283
|
+
from pycode_kg.layout3d import AlliumLayout, LayerCakeLayout
|
|
284
|
+
|
|
285
|
+
viz.status = "Setting up visualization..."
|
|
286
|
+
QApplication.processEvents()
|
|
287
|
+
|
|
288
|
+
plotter.clear_actors()
|
|
289
|
+
plotter.enable_anti_aliasing("msaa")
|
|
290
|
+
plotter.set_background("white", top="lightblue") # type: ignore[arg-type]
|
|
291
|
+
plotter.add_axes() # type: ignore[call-arg]
|
|
292
|
+
|
|
293
|
+
# Add ground plane
|
|
294
|
+
ground_size = 300
|
|
295
|
+
ground = pv.Plane(center=(0, 0, 0), direction=(0, 0, 1), i_size=ground_size, j_size=ground_size)
|
|
296
|
+
plotter.add_mesh(ground, color="lightgray", opacity=1.0, name="ground")
|
|
297
|
+
|
|
298
|
+
# Add cake stand (cylinder base + disk platform)
|
|
299
|
+
stand_height = 20
|
|
300
|
+
stand_radius = 80
|
|
301
|
+
cylinder = pv.Cylinder(
|
|
302
|
+
center=(0, 0, stand_height / 2),
|
|
303
|
+
direction=(0, 0, 1),
|
|
304
|
+
radius=8,
|
|
305
|
+
height=stand_height,
|
|
306
|
+
resolution=32,
|
|
307
|
+
)
|
|
308
|
+
plotter.add_mesh(cylinder, color="tan", opacity=1.0, smooth_shading=True, name="stand_cylinder")
|
|
309
|
+
|
|
310
|
+
platform = pv.Cylinder(
|
|
311
|
+
center=(0, 0, stand_height + 2),
|
|
312
|
+
direction=(0, 0, 1),
|
|
313
|
+
radius=stand_radius,
|
|
314
|
+
height=4,
|
|
315
|
+
resolution=32,
|
|
316
|
+
)
|
|
317
|
+
plotter.add_mesh(
|
|
318
|
+
platform, color="burlywood", opacity=1.0, smooth_shading=True, name="stand_disk"
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# -- Layout
|
|
322
|
+
layout = (
|
|
323
|
+
AlliumLayout()
|
|
324
|
+
if viz.layout_name == "allium"
|
|
325
|
+
else LayerCakeLayout(disc_radius=150.0, layer_gap=20.0)
|
|
326
|
+
)
|
|
327
|
+
# Always compute on ALL nodes for stable positions; filtering happens in display
|
|
328
|
+
|
|
329
|
+
all_nodes_for_layout = viz.nodes # full set for stable layout
|
|
330
|
+
all_edges_for_layout = viz.edges
|
|
331
|
+
positions = layout.compute(all_nodes_for_layout, all_edges_for_layout)
|
|
332
|
+
|
|
333
|
+
# Lift all nodes to sit on top of the cake stand platform
|
|
334
|
+
# Platform is centered at (0, 0, stand_height + 2) with height 4, so top is at stand_height + 4
|
|
335
|
+
platform_top = stand_height + 4
|
|
336
|
+
for node_id in positions:
|
|
337
|
+
pos = positions[node_id]
|
|
338
|
+
positions[node_id] = np.array([pos[0], pos[1], pos[2] + platform_top])
|
|
339
|
+
|
|
340
|
+
# -- LOD tier
|
|
341
|
+
n_visible = len(nodes)
|
|
342
|
+
lod = "high" if n_visible <= LOD_HIGH else "low" if n_visible <= LOD_LOW else "points"
|
|
343
|
+
|
|
344
|
+
# -- Build per-kind MultiBlocks
|
|
345
|
+
kind_blocks: dict[str, pv.MultiBlock] = {k: pv.MultiBlock() for k in KIND_SIZE}
|
|
346
|
+
actor_to_node: dict[str, dict] = {}
|
|
347
|
+
kind_counters: dict[str, int] = {k: 0 for k in KIND_SIZE}
|
|
348
|
+
|
|
349
|
+
node_id_set = {n.id for n in nodes}
|
|
350
|
+
|
|
351
|
+
for node in nodes:
|
|
352
|
+
pos = positions.get(node.id) # type: ignore[assignment]
|
|
353
|
+
if pos is None:
|
|
354
|
+
continue
|
|
355
|
+
kind = node.kind
|
|
356
|
+
if kind not in KIND_SIZE:
|
|
357
|
+
kind = "symbol"
|
|
358
|
+
|
|
359
|
+
mesh = _make_node_mesh(kind, pos, KIND_SIZE[kind], lod)
|
|
360
|
+
kind_blocks[kind].append(mesh)
|
|
361
|
+
|
|
362
|
+
mesh_id = f"{kind}_{kind_counters[kind]}"
|
|
363
|
+
kind_counters[kind] += 1
|
|
364
|
+
actor_to_node[mesh_id] = {
|
|
365
|
+
"kind": kind,
|
|
366
|
+
"id": node.id,
|
|
367
|
+
"name": node.name,
|
|
368
|
+
"module_path": node.module_path,
|
|
369
|
+
"lineno": node.lineno,
|
|
370
|
+
"end_lineno": node.end_lineno,
|
|
371
|
+
"docstring": node.docstring,
|
|
372
|
+
"position": pos,
|
|
373
|
+
"mesh": mesh,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
# Progress update
|
|
377
|
+
total_nodes = len(nodes)
|
|
378
|
+
update_every = max(1, total_nodes // 10)
|
|
379
|
+
rendered = sum(kind_counters.values())
|
|
380
|
+
if rendered % update_every == 0 or rendered == total_nodes:
|
|
381
|
+
pct = int(rendered / total_nodes * 100)
|
|
382
|
+
bar = "█" * (pct // 10) + "░" * ((100 - pct) // 10)
|
|
383
|
+
viz.status = f"Rendering nodes | {bar} {pct}% ({rendered}/{total_nodes})"
|
|
384
|
+
QApplication.processEvents()
|
|
385
|
+
|
|
386
|
+
# -- Add node MultiBlocks
|
|
387
|
+
for kind, block in kind_blocks.items():
|
|
388
|
+
if block.n_blocks > 0:
|
|
389
|
+
# Only apply smooth shading to cylinders (function nodes)
|
|
390
|
+
plotter.add_mesh(
|
|
391
|
+
block,
|
|
392
|
+
color=KIND_COLOR[kind],
|
|
393
|
+
show_edges=False,
|
|
394
|
+
smooth_shading=(kind == "function"),
|
|
395
|
+
name=f"{kind}_nodes",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# -- Edge rendering
|
|
399
|
+
rel_to_show = set()
|
|
400
|
+
if viz.show_calls:
|
|
401
|
+
rel_to_show.add("CALLS")
|
|
402
|
+
if viz.show_imports:
|
|
403
|
+
rel_to_show.add("IMPORTS")
|
|
404
|
+
if viz.show_inherits:
|
|
405
|
+
rel_to_show.add("INHERITS")
|
|
406
|
+
if viz.show_contains:
|
|
407
|
+
rel_to_show.add("CONTAINS")
|
|
408
|
+
|
|
409
|
+
rel_blocks: dict[str, pv.MultiBlock] = {r: pv.MultiBlock() for r in rel_to_show}
|
|
410
|
+
|
|
411
|
+
viz.status = "Rendering edges..."
|
|
412
|
+
QApplication.processEvents()
|
|
413
|
+
|
|
414
|
+
for edge in edges:
|
|
415
|
+
if edge.rel not in rel_to_show:
|
|
416
|
+
continue
|
|
417
|
+
if edge.src not in node_id_set or edge.dst not in node_id_set:
|
|
418
|
+
continue
|
|
419
|
+
p1, p2 = positions.get(edge.src), positions.get(edge.dst)
|
|
420
|
+
if p1 is None or p2 is None:
|
|
421
|
+
continue
|
|
422
|
+
|
|
423
|
+
if edge.rel == "CONTAINS":
|
|
424
|
+
rel_blocks["CONTAINS"].append(pv.Line(p1, p2))
|
|
425
|
+
else:
|
|
426
|
+
arc_pts = _arc_points(p1, p2)
|
|
427
|
+
rel_blocks[edge.rel].append(pv.Spline(arc_pts, n_points=24))
|
|
428
|
+
|
|
429
|
+
for rel, block in rel_blocks.items():
|
|
430
|
+
if block.n_blocks > 0:
|
|
431
|
+
is_contains = rel == "CONTAINS"
|
|
432
|
+
plotter.add_mesh(
|
|
433
|
+
block,
|
|
434
|
+
color=REL_COLOR[rel],
|
|
435
|
+
line_width=4.0 if is_contains else 2.5,
|
|
436
|
+
opacity=0.5 if is_contains else 1.0,
|
|
437
|
+
smooth_shading=True,
|
|
438
|
+
name=f"{rel.lower()}_edges",
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# -- Stats
|
|
442
|
+
total_faces = 0
|
|
443
|
+
for block in kind_blocks.values():
|
|
444
|
+
for i in range(block.n_blocks):
|
|
445
|
+
mesh = block[i]
|
|
446
|
+
if hasattr(mesh, "n_faces_strict"):
|
|
447
|
+
total_faces += mesh.n_faces_strict # type: ignore[union-attr]
|
|
448
|
+
viz.num_faces = total_faces
|
|
449
|
+
|
|
450
|
+
title = (
|
|
451
|
+
f"PyCodeKG 3D | {Path(viz.db_path).name} | "
|
|
452
|
+
f"Modules: {viz.num_modules} Classes: {viz.num_classes} "
|
|
453
|
+
f"Methods: {viz.num_methods} Functions: {viz.num_functions} "
|
|
454
|
+
f"Faces: {total_faces}"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
plotter.reset_camera() # type: ignore[call-arg]
|
|
458
|
+
plotter.view_isometric() # type: ignore[call-arg]
|
|
459
|
+
plotter.render()
|
|
460
|
+
plotter.camera.zoom(3)
|
|
461
|
+
|
|
462
|
+
viz.status = "Scene generation complete."
|
|
463
|
+
QApplication.processEvents()
|
|
464
|
+
|
|
465
|
+
return plotter, title, actor_to_node
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# ---------------------------------------------------------------------------
|
|
469
|
+
# KGVisualizer — adapted from PackageVisualizer
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class KGVisualizer(param.Parameterized):
|
|
474
|
+
"""
|
|
475
|
+
Data and state model for the PyCodeKG 3-D visualiser.
|
|
476
|
+
Adapted from ``PackageVisualizer`` in *repo_vis*.
|
|
477
|
+
|
|
478
|
+
Reactive attributes (via ``param``) drive the Qt control panel;
|
|
479
|
+
watched parameters trigger graph reload or UI updates automatically.
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
db_path: str = param.String(default=DEFAULT_DB, doc="SQLite database path")
|
|
483
|
+
layout_name: str = param.Selector(
|
|
484
|
+
objects=["allium", "cake"], default="allium", doc="3-D layout strategy"
|
|
485
|
+
)
|
|
486
|
+
save_path: str = param.String(default=DEFAULT_SAVE, doc="Save path stem")
|
|
487
|
+
save_format: str = param.Selector(
|
|
488
|
+
objects=["html", "png", "jpg"], default="html", doc="Export format"
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# Node kind visibility
|
|
492
|
+
show_methods: bool = param.Boolean(default=True, doc="Render method nodes")
|
|
493
|
+
show_symbols: bool = param.Boolean(default=False, doc="Render symbol stub nodes")
|
|
494
|
+
# Edge visibility
|
|
495
|
+
show_calls: bool = param.Boolean(default=True, doc="Render CALLS edges")
|
|
496
|
+
show_imports: bool = param.Boolean(default=True, doc="Render IMPORTS edges")
|
|
497
|
+
show_inherits: bool = param.Boolean(default=True, doc="Render INHERITS edges")
|
|
498
|
+
show_contains: bool = param.Boolean(default=True, doc="Render CONTAINS edges")
|
|
499
|
+
|
|
500
|
+
# Status / title
|
|
501
|
+
status: str = param.String(default="Ready", doc="Status bar text")
|
|
502
|
+
window_title: str = param.String(default=f"PyCodeKG 3D v{__version__}", doc="Window title")
|
|
503
|
+
|
|
504
|
+
# Stats
|
|
505
|
+
num_modules: int = param.Integer(default=0)
|
|
506
|
+
num_classes: int = param.Integer(default=0)
|
|
507
|
+
num_functions: int = param.Integer(default=0)
|
|
508
|
+
num_methods: int = param.Integer(default=0)
|
|
509
|
+
num_faces: int = param.Integer(default=0)
|
|
510
|
+
|
|
511
|
+
# Module selector data
|
|
512
|
+
available_modules: list[str] = param.List(default=[], doc="Available module names")
|
|
513
|
+
selected_modules: list[str] = param.ListSelector(
|
|
514
|
+
default=[], objects=[], doc="Selected module names"
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
def __init__(self, plotter: pv.Plotter | None = None, **params) -> None:
|
|
518
|
+
"""
|
|
519
|
+
Initialise the visualiser data model.
|
|
520
|
+
|
|
521
|
+
:param plotter: The ``QtInteractor`` to render into.
|
|
522
|
+
:param params: Additional ``param`` keyword arguments.
|
|
523
|
+
"""
|
|
524
|
+
super().__init__(**params)
|
|
525
|
+
self.plotter: pv.Plotter | None = plotter
|
|
526
|
+
self.nodes: list = []
|
|
527
|
+
self.edges: list = []
|
|
528
|
+
self.actor_to_node: dict[str, dict] = {}
|
|
529
|
+
self._load_graph()
|
|
530
|
+
|
|
531
|
+
@param.depends("db_path", watch=True)
|
|
532
|
+
def _load_graph(self) -> None:
|
|
533
|
+
"""Reload nodes and edges from the SQLite database."""
|
|
534
|
+
from pycode_kg.layout3d import LayoutEdge, LayoutNode
|
|
535
|
+
from pycode_kg.store import GraphStore
|
|
536
|
+
|
|
537
|
+
db = Path(self.db_path)
|
|
538
|
+
if not db.exists():
|
|
539
|
+
self.status = f"Error: database not found: {db}"
|
|
540
|
+
return
|
|
541
|
+
|
|
542
|
+
self.status = "Loading graph..."
|
|
543
|
+
QApplication.processEvents()
|
|
544
|
+
|
|
545
|
+
with GraphStore(db) as store:
|
|
546
|
+
raw_nodes = store.query_nodes()
|
|
547
|
+
node_ids = {n["id"] for n in raw_nodes}
|
|
548
|
+
raw_edges = store.edges_within(node_ids)
|
|
549
|
+
|
|
550
|
+
self.nodes = [LayoutNode.from_dict(n) for n in raw_nodes]
|
|
551
|
+
self.edges = [LayoutEdge.from_dict(e) for e in raw_edges]
|
|
552
|
+
|
|
553
|
+
counts = Counter(n.kind for n in self.nodes)
|
|
554
|
+
self.num_modules = counts.get("module", 0)
|
|
555
|
+
self.num_classes = counts.get("class", 0)
|
|
556
|
+
self.num_functions = counts.get("function", 0)
|
|
557
|
+
self.num_methods = counts.get("method", 0)
|
|
558
|
+
|
|
559
|
+
mod_names = sorted(n.name for n in self.nodes if n.kind == "module")
|
|
560
|
+
self.available_modules = mod_names
|
|
561
|
+
self.param.selected_modules.objects = mod_names
|
|
562
|
+
self.selected_modules = []
|
|
563
|
+
|
|
564
|
+
self.window_title = (
|
|
565
|
+
f"PyCodeKG 3D | {db.name} | "
|
|
566
|
+
f"Modules: {self.num_modules} Classes: {self.num_classes} "
|
|
567
|
+
f"Methods: {self.num_methods} Functions: {self.num_functions}"
|
|
568
|
+
)
|
|
569
|
+
self.status = f"Loaded: {len(self.nodes)} nodes, {len(self.edges)} edges"
|
|
570
|
+
|
|
571
|
+
if self.plotter and hasattr(self.plotter, "clear_actors"):
|
|
572
|
+
self.plotter.clear_actors()
|
|
573
|
+
|
|
574
|
+
def visualize(self) -> None:
|
|
575
|
+
"""
|
|
576
|
+
Build and render the 3-D scene using the current settings.
|
|
577
|
+
|
|
578
|
+
Applies the selected module filter, then delegates to
|
|
579
|
+
:func:`create_kg_visualization`.
|
|
580
|
+
"""
|
|
581
|
+
if not self.plotter:
|
|
582
|
+
return
|
|
583
|
+
if not self.nodes:
|
|
584
|
+
self.status = "No data — check DB path."
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
# Apply module filter
|
|
588
|
+
if self.selected_modules:
|
|
589
|
+
contains_children: dict[str, list[str]] = {}
|
|
590
|
+
for e in self.edges:
|
|
591
|
+
if e.rel == "CONTAINS":
|
|
592
|
+
contains_children.setdefault(e.src, []).append(e.dst)
|
|
593
|
+
|
|
594
|
+
def subtree(root_id: str) -> set:
|
|
595
|
+
"""Collect all node IDs in the CONTAINS subtree rooted at *root_id*.
|
|
596
|
+
|
|
597
|
+
:param root_id: ID of the root node.
|
|
598
|
+
:return: Set of node IDs reachable via CONTAINS edges.
|
|
599
|
+
"""
|
|
600
|
+
s = {root_id}
|
|
601
|
+
for c in contains_children.get(root_id, []):
|
|
602
|
+
s |= subtree(c)
|
|
603
|
+
return s
|
|
604
|
+
|
|
605
|
+
in_scope: set = set()
|
|
606
|
+
for mod_name in self.selected_modules:
|
|
607
|
+
mod_node = next(
|
|
608
|
+
(n for n in self.nodes if n.kind == "module" and n.name == mod_name),
|
|
609
|
+
None,
|
|
610
|
+
)
|
|
611
|
+
if mod_node:
|
|
612
|
+
in_scope |= subtree(mod_node.id)
|
|
613
|
+
|
|
614
|
+
# Respect show_methods / show_symbols
|
|
615
|
+
visible_nodes = [
|
|
616
|
+
n for n in self.nodes if n.id in in_scope and self._kind_visible(n.kind)
|
|
617
|
+
]
|
|
618
|
+
else:
|
|
619
|
+
visible_nodes = [n for n in self.nodes if self._kind_visible(n.kind)]
|
|
620
|
+
|
|
621
|
+
try:
|
|
622
|
+
_, title, actor_to_node = create_kg_visualization(
|
|
623
|
+
self, visible_nodes, self.edges, self.plotter
|
|
624
|
+
)
|
|
625
|
+
self.actor_to_node = actor_to_node
|
|
626
|
+
self.window_title = title
|
|
627
|
+
except (ValueError, RuntimeError) as exc:
|
|
628
|
+
self.status = f"Error: {exc}"
|
|
629
|
+
|
|
630
|
+
def _kind_visible(self, kind: str) -> bool:
|
|
631
|
+
"""Return ``True`` if nodes of this kind should be rendered."""
|
|
632
|
+
if kind == "method" and not self.show_methods:
|
|
633
|
+
return False
|
|
634
|
+
if kind == "symbol" and not self.show_symbols:
|
|
635
|
+
return False
|
|
636
|
+
return True
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
# ---------------------------------------------------------------------------
|
|
640
|
+
# MainWindow — adapted from repo_vis MainWindow
|
|
641
|
+
# ---------------------------------------------------------------------------
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class MainWindow(QMainWindow):
|
|
645
|
+
"""
|
|
646
|
+
Full Qt main window for the PyCodeKG 3-D visualiser.
|
|
647
|
+
|
|
648
|
+
Layout mirrors *repo_vis/pkg_visualizer/pkg_visualizer.py*:
|
|
649
|
+
- **Left** — control panel (DB path, layout, module filter, render options)
|
|
650
|
+
- **Right** — PyVista ``QtInteractor`` + button row
|
|
651
|
+
|
|
652
|
+
Adapted from ``MainWindow`` in *repo_vis* (Eric G. Suchanek, PhD).
|
|
653
|
+
"""
|
|
654
|
+
|
|
655
|
+
status_changed: pyqtSignal = pyqtSignal(str)
|
|
656
|
+
|
|
657
|
+
def __init__(
|
|
658
|
+
self,
|
|
659
|
+
db_path: str = DEFAULT_DB,
|
|
660
|
+
save_path: str = DEFAULT_SAVE,
|
|
661
|
+
width: int = 1400,
|
|
662
|
+
height: int = 900,
|
|
663
|
+
) -> None:
|
|
664
|
+
"""
|
|
665
|
+
Initialise the main window.
|
|
666
|
+
|
|
667
|
+
:param db_path: Path to the ``.pycodekg/graph.sqlite`` file.
|
|
668
|
+
:param save_path: Default output file stem for exports.
|
|
669
|
+
:param width: Initial window width in pixels.
|
|
670
|
+
:param height: Initial window height in pixels.
|
|
671
|
+
"""
|
|
672
|
+
super().__init__()
|
|
673
|
+
|
|
674
|
+
self.timer = None
|
|
675
|
+
self.current_frame = 0
|
|
676
|
+
self._current_picked_actor = None
|
|
677
|
+
self._current_popup: DocstringPopup | None = None
|
|
678
|
+
self._original_camera_state = None
|
|
679
|
+
|
|
680
|
+
self.setGeometry(100, 100, width, height)
|
|
681
|
+
|
|
682
|
+
self.vtk_plotter: QtInteractor = QtInteractor(self)
|
|
683
|
+
self.visualizer: KGVisualizer = KGVisualizer(
|
|
684
|
+
plotter=self.vtk_plotter,
|
|
685
|
+
db_path=db_path,
|
|
686
|
+
save_path=save_path,
|
|
687
|
+
)
|
|
688
|
+
self.plotter = self.vtk_plotter # convenience alias
|
|
689
|
+
|
|
690
|
+
self.setWindowTitle(self.visualizer.window_title)
|
|
691
|
+
|
|
692
|
+
# ── Central widget ──────────────────────────────────────────────────
|
|
693
|
+
central = QWidget()
|
|
694
|
+
self.setCentralWidget(central)
|
|
695
|
+
main_layout = QHBoxLayout(central)
|
|
696
|
+
|
|
697
|
+
self.setStyleSheet(
|
|
698
|
+
"""
|
|
699
|
+
QPushButton { background-color: #4CAF50; color: white; border: none;
|
|
700
|
+
border-radius: 3px; padding: 6px; margin: 2px; }
|
|
701
|
+
QPushButton#reset-view { background-color: #FFEB3B; color: black; }
|
|
702
|
+
QPushButton#reset-all { background-color: #E53935; color: white; }
|
|
703
|
+
QPushButton { font-size: 12px; }
|
|
704
|
+
"""
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
ctrl_widget = self._build_control_panel()
|
|
708
|
+
vis_widget = self._build_viewport_panel()
|
|
709
|
+
|
|
710
|
+
main_layout.addWidget(ctrl_widget)
|
|
711
|
+
main_layout.addWidget(vis_widget, stretch=1)
|
|
712
|
+
main_layout.setContentsMargins(0, 0, 0, 0)
|
|
713
|
+
main_layout.setSpacing(5)
|
|
714
|
+
|
|
715
|
+
central.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
716
|
+
ctrl_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.MinimumExpanding)
|
|
717
|
+
|
|
718
|
+
self._setup_mesh_picking()
|
|
719
|
+
self._connect_signals()
|
|
720
|
+
|
|
721
|
+
font = QFont("Arial", 12)
|
|
722
|
+
self.setFont(font)
|
|
723
|
+
self.resize(width, height)
|
|
724
|
+
|
|
725
|
+
# Perform initial render on launch
|
|
726
|
+
QApplication.processEvents()
|
|
727
|
+
self.on_visualize_clicked()
|
|
728
|
+
|
|
729
|
+
# ── UI builder helpers ───────────────────────────────────────────────────
|
|
730
|
+
|
|
731
|
+
@staticmethod
|
|
732
|
+
def _h2(text: str) -> QLabel:
|
|
733
|
+
"""Create a bold section-header QLabel.
|
|
734
|
+
|
|
735
|
+
:param text: Header text (rendered at 13 px bold).
|
|
736
|
+
:return: Styled :class:`QLabel` instance.
|
|
737
|
+
"""
|
|
738
|
+
lbl = QLabel(f"<b style='font-size:13px;'>{text}</b>")
|
|
739
|
+
lbl.setStyleSheet("background:transparent; border:none;")
|
|
740
|
+
return lbl
|
|
741
|
+
|
|
742
|
+
@staticmethod
|
|
743
|
+
def _lbl(text: str) -> QLabel:
|
|
744
|
+
"""Create a plain-text QLabel with transparent background.
|
|
745
|
+
|
|
746
|
+
:param text: Label text (may contain basic HTML).
|
|
747
|
+
:return: Styled :class:`QLabel` instance.
|
|
748
|
+
"""
|
|
749
|
+
lbl = QLabel(text)
|
|
750
|
+
lbl.setStyleSheet("background:transparent; border:none;")
|
|
751
|
+
return lbl
|
|
752
|
+
|
|
753
|
+
def _build_input_params(self, ctrl: QVBoxLayout) -> None:
|
|
754
|
+
"""Populate *ctrl* with the Input Parameters section.
|
|
755
|
+
|
|
756
|
+
Adds widgets for DB path, layout selector, save path, and save format.
|
|
757
|
+
|
|
758
|
+
:param ctrl: The control-panel layout to populate.
|
|
759
|
+
"""
|
|
760
|
+
ctrl.addWidget(self._h2("Input Parameters"))
|
|
761
|
+
|
|
762
|
+
ctrl.addWidget(self._lbl("<b>Database Path</b>"))
|
|
763
|
+
self.db_path_input = QLineEdit(self.visualizer.db_path)
|
|
764
|
+
self.db_path_input.setPlaceholderText(".pycodekg/graph.sqlite")
|
|
765
|
+
ctrl.addWidget(self.db_path_input)
|
|
766
|
+
|
|
767
|
+
ctrl.addWidget(self._lbl("<b>Layout</b>"))
|
|
768
|
+
self.layout_select = QComboBox()
|
|
769
|
+
self.layout_select.addItems(["allium", "cake"])
|
|
770
|
+
self.layout_select.setCurrentText(self.visualizer.layout_name)
|
|
771
|
+
ctrl.addWidget(self.layout_select)
|
|
772
|
+
|
|
773
|
+
ctrl.addWidget(self._lbl("<b>Save Path</b>"))
|
|
774
|
+
self.save_path_input = QLineEdit(self.visualizer.save_path)
|
|
775
|
+
ctrl.addWidget(self.save_path_input)
|
|
776
|
+
|
|
777
|
+
ctrl.addWidget(self._lbl("<b>Save Format</b>"))
|
|
778
|
+
self.save_format_select = QComboBox()
|
|
779
|
+
self.save_format_select.addItems(["html", "png", "jpg"])
|
|
780
|
+
self.save_format_select.setCurrentText(self.visualizer.save_format)
|
|
781
|
+
ctrl.addWidget(self.save_format_select)
|
|
782
|
+
|
|
783
|
+
def _build_module_filter(self, ctrl: QVBoxLayout) -> None:
|
|
784
|
+
"""Populate *ctrl* with the Module Filter section.
|
|
785
|
+
|
|
786
|
+
Adds a single-selection list pre-filled with available modules.
|
|
787
|
+
|
|
788
|
+
:param ctrl: The control-panel layout to populate.
|
|
789
|
+
"""
|
|
790
|
+
ctrl.addWidget(self._h2("Module Filter"))
|
|
791
|
+
ctrl.addWidget(self._lbl("Select module (empty = all):"))
|
|
792
|
+
self.module_selector = QListWidget()
|
|
793
|
+
self.module_selector.setSelectionMode(QListWidget.SingleSelection)
|
|
794
|
+
self.module_selector.setMaximumHeight(90)
|
|
795
|
+
for name in self.visualizer.available_modules:
|
|
796
|
+
self.module_selector.addItem(name)
|
|
797
|
+
ctrl.addWidget(self.module_selector)
|
|
798
|
+
|
|
799
|
+
def _build_render_options(self, ctrl: QVBoxLayout) -> None:
|
|
800
|
+
"""Populate *ctrl* with the Render Options and Graph Statistics sections.
|
|
801
|
+
|
|
802
|
+
Adds node-kind checkboxes, edge-type checkboxes, and a live stats label.
|
|
803
|
+
|
|
804
|
+
:param ctrl: The control-panel layout to populate.
|
|
805
|
+
"""
|
|
806
|
+
ctrl.addWidget(self._h2("Render Options"))
|
|
807
|
+
|
|
808
|
+
cb_row1 = QHBoxLayout()
|
|
809
|
+
self.cb_methods = QCheckBox("Methods")
|
|
810
|
+
self.cb_methods.setChecked(self.visualizer.show_methods)
|
|
811
|
+
self.cb_symbols = QCheckBox("Symbols")
|
|
812
|
+
self.cb_symbols.setChecked(self.visualizer.show_symbols)
|
|
813
|
+
self.cb_contains = QCheckBox("CONTAINS")
|
|
814
|
+
self.cb_contains.setChecked(self.visualizer.show_contains)
|
|
815
|
+
for w in (self.cb_methods, self.cb_symbols, self.cb_contains):
|
|
816
|
+
cb_row1.addWidget(w)
|
|
817
|
+
ctrl.addLayout(cb_row1)
|
|
818
|
+
|
|
819
|
+
ctrl.addWidget(self._lbl("<b>Edge Types</b>"))
|
|
820
|
+
cb_row2 = QHBoxLayout()
|
|
821
|
+
self.cb_calls = QCheckBox("CALLS")
|
|
822
|
+
self.cb_calls.setChecked(self.visualizer.show_calls)
|
|
823
|
+
self.cb_imports = QCheckBox("IMPORTS")
|
|
824
|
+
self.cb_imports.setChecked(self.visualizer.show_imports)
|
|
825
|
+
self.cb_inherits = QCheckBox("INHERITS")
|
|
826
|
+
self.cb_inherits.setChecked(self.visualizer.show_inherits)
|
|
827
|
+
for w in (self.cb_calls, self.cb_imports, self.cb_inherits):
|
|
828
|
+
cb_row2.addWidget(w)
|
|
829
|
+
ctrl.addLayout(cb_row2)
|
|
830
|
+
|
|
831
|
+
ctrl.addWidget(self._lbl("<b>Graph Statistics</b>"))
|
|
832
|
+
self.stats_label = QLabel(self._stats_text())
|
|
833
|
+
self.stats_label.setWordWrap(True)
|
|
834
|
+
self.stats_label.setStyleSheet(
|
|
835
|
+
"background-color:white; color:black; padding:5px; border-radius:3px;"
|
|
836
|
+
)
|
|
837
|
+
ctrl.addWidget(self.stats_label)
|
|
838
|
+
|
|
839
|
+
def _build_action_buttons(self, ctrl: QVBoxLayout) -> None:
|
|
840
|
+
"""Populate *ctrl* with the action-button row at the bottom of the panel.
|
|
841
|
+
|
|
842
|
+
Adds a prominent Render Graph button and a secondary row with
|
|
843
|
+
Show Docstring and Save View.
|
|
844
|
+
|
|
845
|
+
:param ctrl: The control-panel layout to populate.
|
|
846
|
+
"""
|
|
847
|
+
ctrl.addStretch()
|
|
848
|
+
|
|
849
|
+
self.visualize_button = QPushButton("Render Graph")
|
|
850
|
+
self.visualize_button.setMinimumHeight(40)
|
|
851
|
+
self.visualize_button.setStyleSheet("QPushButton { font-size: 14px; font-weight: bold; }")
|
|
852
|
+
ctrl.addWidget(self.visualize_button)
|
|
853
|
+
|
|
854
|
+
btn_row = QHBoxLayout()
|
|
855
|
+
self.show_docstring_button = QPushButton("Show Docstring")
|
|
856
|
+
self.save_button = QPushButton("Save View")
|
|
857
|
+
btn_row.addWidget(self.show_docstring_button)
|
|
858
|
+
btn_row.addWidget(self.save_button)
|
|
859
|
+
ctrl.addLayout(btn_row)
|
|
860
|
+
|
|
861
|
+
def _build_control_panel(self) -> QWidget:
|
|
862
|
+
"""Build and return the left-side control-panel widget.
|
|
863
|
+
|
|
864
|
+
Assembles Input Parameters, Module Filter, Render Options, and
|
|
865
|
+
action buttons into a fixed-width :class:`QWidget`.
|
|
866
|
+
|
|
867
|
+
:return: A :class:`QWidget` containing the complete control panel.
|
|
868
|
+
"""
|
|
869
|
+
ctrl = QVBoxLayout()
|
|
870
|
+
ctrl.setSpacing(12)
|
|
871
|
+
ctrl.setContentsMargins(6, 6, 6, 6)
|
|
872
|
+
|
|
873
|
+
self._build_input_params(ctrl)
|
|
874
|
+
self._build_module_filter(ctrl)
|
|
875
|
+
self._build_render_options(ctrl)
|
|
876
|
+
self._build_action_buttons(ctrl)
|
|
877
|
+
|
|
878
|
+
widget = QWidget()
|
|
879
|
+
widget.setLayout(ctrl)
|
|
880
|
+
widget.setFixedWidth(CONTROL_PANEL_WIDTH)
|
|
881
|
+
return widget
|
|
882
|
+
|
|
883
|
+
def _build_viewport_panel(self) -> QWidget:
|
|
884
|
+
"""Build and return the right-side viewport widget.
|
|
885
|
+
|
|
886
|
+
Wraps the PyVista :class:`QtInteractor` and a bottom button row
|
|
887
|
+
(Reset View, Reset Settings, status display) in a :class:`QWidget`.
|
|
888
|
+
|
|
889
|
+
:return: A :class:`QWidget` containing the 3-D viewport and controls.
|
|
890
|
+
"""
|
|
891
|
+
vis = QVBoxLayout()
|
|
892
|
+
vis.setSpacing(10)
|
|
893
|
+
vis.setContentsMargins(10, 10, 10, 10)
|
|
894
|
+
vis.addWidget(self.vtk_plotter, stretch=1)
|
|
895
|
+
vis.addStretch()
|
|
896
|
+
|
|
897
|
+
btn_row = QHBoxLayout()
|
|
898
|
+
|
|
899
|
+
self.reset_view_button = QPushButton("Reset View")
|
|
900
|
+
self.reset_view_button.setObjectName("reset-view")
|
|
901
|
+
self.reset_view_button.setFixedWidth(BUTTON_WIDTH)
|
|
902
|
+
btn_row.addWidget(self.reset_view_button)
|
|
903
|
+
|
|
904
|
+
self.reset_settings_button = QPushButton("Reset Settings")
|
|
905
|
+
self.reset_settings_button.setObjectName("reset-all")
|
|
906
|
+
self.reset_settings_button.setFixedWidth(BUTTON_WIDTH)
|
|
907
|
+
btn_row.addWidget(self.reset_settings_button)
|
|
908
|
+
|
|
909
|
+
self.status_display = QLabel("Ready")
|
|
910
|
+
self.status_display.setTextInteractionFlags(Qt.TextBrowserInteraction) # type: ignore[attr-defined]
|
|
911
|
+
self.status_display.setStyleSheet(
|
|
912
|
+
"font-weight:bold; font-size:13px; background-color:white; color:black;"
|
|
913
|
+
)
|
|
914
|
+
btn_row.addWidget(self.status_display, stretch=1)
|
|
915
|
+
|
|
916
|
+
vis.addLayout(btn_row)
|
|
917
|
+
|
|
918
|
+
widget = QWidget()
|
|
919
|
+
widget.setLayout(vis)
|
|
920
|
+
return widget
|
|
921
|
+
|
|
922
|
+
def _setup_mesh_picking(self) -> None:
|
|
923
|
+
"""Configure PyVista mesh picking on the VTK plotter.
|
|
924
|
+
|
|
925
|
+
Enables actor-based mesh picking with a tight tolerance, mirroring
|
|
926
|
+
the *repo_vis* picking setup.
|
|
927
|
+
"""
|
|
928
|
+
self.vtk_plotter.enable_mesh_picking(
|
|
929
|
+
callback=self.on_pick,
|
|
930
|
+
show=False,
|
|
931
|
+
show_actors=False,
|
|
932
|
+
show_message=False,
|
|
933
|
+
font_size=14,
|
|
934
|
+
left_clicking=False,
|
|
935
|
+
use_actor=True,
|
|
936
|
+
through=True,
|
|
937
|
+
)
|
|
938
|
+
if hasattr(self.vtk_plotter, "picker"):
|
|
939
|
+
self.vtk_plotter.picker.SetTolerance(0.005)
|
|
940
|
+
self.vtk_plotter.picker.SetPickFromList(0)
|
|
941
|
+
|
|
942
|
+
def _connect_signals(self) -> None:
|
|
943
|
+
"""Wire all Qt signal/slot connections for the main window.
|
|
944
|
+
|
|
945
|
+
Connects control-panel inputs to visualizer properties, action
|
|
946
|
+
buttons to their handlers, and param watchers for live updates.
|
|
947
|
+
"""
|
|
948
|
+
self.db_path_input.editingFinished.connect(self.update_db_path)
|
|
949
|
+
self.layout_select.currentTextChanged.connect(self.update_layout)
|
|
950
|
+
self.save_path_input.textChanged.connect(lambda t: setattr(self.visualizer, "save_path", t))
|
|
951
|
+
self.save_format_select.currentTextChanged.connect(
|
|
952
|
+
lambda t: setattr(self.visualizer, "save_format", t)
|
|
953
|
+
)
|
|
954
|
+
self.module_selector.itemSelectionChanged.connect(self.update_selected_modules)
|
|
955
|
+
|
|
956
|
+
self.cb_methods.stateChanged.connect(
|
|
957
|
+
lambda s: setattr(self.visualizer, "show_methods", s == Qt.Checked) # type: ignore[attr-defined]
|
|
958
|
+
)
|
|
959
|
+
self.cb_symbols.stateChanged.connect(
|
|
960
|
+
lambda s: setattr(self.visualizer, "show_symbols", s == Qt.Checked) # type: ignore[attr-defined]
|
|
961
|
+
)
|
|
962
|
+
self.cb_contains.stateChanged.connect(
|
|
963
|
+
lambda s: setattr(self.visualizer, "show_contains", s == Qt.Checked) # type: ignore[attr-defined]
|
|
964
|
+
)
|
|
965
|
+
self.cb_calls.stateChanged.connect(
|
|
966
|
+
lambda s: setattr(self.visualizer, "show_calls", s == Qt.Checked) # type: ignore[attr-defined]
|
|
967
|
+
)
|
|
968
|
+
self.cb_imports.stateChanged.connect(
|
|
969
|
+
lambda s: setattr(self.visualizer, "show_imports", s == Qt.Checked) # type: ignore[attr-defined]
|
|
970
|
+
)
|
|
971
|
+
self.cb_inherits.stateChanged.connect(
|
|
972
|
+
lambda s: setattr(self.visualizer, "show_inherits", s == Qt.Checked) # type: ignore[attr-defined]
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
self.visualize_button.clicked.connect(self.on_visualize_clicked)
|
|
976
|
+
self.show_docstring_button.clicked.connect(self.show_selected_docstring)
|
|
977
|
+
self.save_button.clicked.connect(self.save_current_view)
|
|
978
|
+
self.reset_view_button.clicked.connect(self.reset_camera)
|
|
979
|
+
self.reset_settings_button.clicked.connect(self.reset_settings)
|
|
980
|
+
|
|
981
|
+
self.status_changed.connect(self.update_status_display)
|
|
982
|
+
self.visualizer.param.watch(self.on_status_change, "status")
|
|
983
|
+
self.visualizer.param.watch(self.update_module_selector, "available_modules")
|
|
984
|
+
self.visualizer.param.watch(self.update_window_title, "window_title")
|
|
985
|
+
self.visualizer.param.watch(
|
|
986
|
+
lambda _: self.stats_label.setText(self._stats_text()), "num_faces"
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
# ── Stats helper ────────────────────────────────────────────────────────
|
|
990
|
+
|
|
991
|
+
def _stats_text(self) -> str:
|
|
992
|
+
"""Build a formatted stats string from the current visualizer state.
|
|
993
|
+
|
|
994
|
+
:return: Multi-line string summarising module, class, method,
|
|
995
|
+
function, and face counts.
|
|
996
|
+
"""
|
|
997
|
+
v = self.visualizer
|
|
998
|
+
return (
|
|
999
|
+
f"Modules: {v.num_modules} Classes: {v.num_classes}\n"
|
|
1000
|
+
f"Methods: {v.num_methods} Functions: {v.num_functions}\n"
|
|
1001
|
+
f"Faces: {v.num_faces}"
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
# ── Control panel updates ───────────────────────────────────────────────
|
|
1005
|
+
|
|
1006
|
+
def update_db_path(self) -> None:
|
|
1007
|
+
"""Handle DB path text field edit-finished."""
|
|
1008
|
+
text = self.db_path_input.text().strip()
|
|
1009
|
+
self.visualizer.db_path = text
|
|
1010
|
+
self.module_selector.clear()
|
|
1011
|
+
for name in self.visualizer.available_modules:
|
|
1012
|
+
self.module_selector.addItem(name)
|
|
1013
|
+
|
|
1014
|
+
def update_layout(self, name: str) -> None:
|
|
1015
|
+
"""Handle layout combo-box change."""
|
|
1016
|
+
self.visualizer.layout_name = name
|
|
1017
|
+
|
|
1018
|
+
def update_selected_modules(self) -> None:
|
|
1019
|
+
"""Sync QListWidget selection to visualizer.selected_modules."""
|
|
1020
|
+
self.visualizer.selected_modules = [
|
|
1021
|
+
item.text() for item in self.module_selector.selectedItems()
|
|
1022
|
+
]
|
|
1023
|
+
|
|
1024
|
+
def update_module_selector(self, event: param.Event) -> None:
|
|
1025
|
+
"""Refresh module list when available_modules changes."""
|
|
1026
|
+
self.module_selector.clear()
|
|
1027
|
+
for name in event.new:
|
|
1028
|
+
self.module_selector.addItem(name)
|
|
1029
|
+
|
|
1030
|
+
def update_window_title(self, event: param.Event) -> None:
|
|
1031
|
+
"""Update the window title bar."""
|
|
1032
|
+
self.setWindowTitle(event.new)
|
|
1033
|
+
|
|
1034
|
+
# ── Visualize ───────────────────────────────────────────────────────────
|
|
1035
|
+
|
|
1036
|
+
def on_visualize_clicked(self) -> None:
|
|
1037
|
+
"""Trigger a full scene rebuild."""
|
|
1038
|
+
self.visualizer.visualize()
|
|
1039
|
+
|
|
1040
|
+
# ── Picking (mirrors repo_vis on_pick exactly) ──────────────────────────
|
|
1041
|
+
|
|
1042
|
+
def on_pick(self, actor) -> None:
|
|
1043
|
+
"""
|
|
1044
|
+
Callback fired when the user right-clicks a node mesh.
|
|
1045
|
+
Finds the nearest node, highlights it, and shows the docstring popup.
|
|
1046
|
+
Adapted from ``MainWindow.on_pick`` in *repo_vis*.
|
|
1047
|
+
"""
|
|
1048
|
+
if not self.visualizer.plotter:
|
|
1049
|
+
return
|
|
1050
|
+
self.reset_actor_appearances()
|
|
1051
|
+
|
|
1052
|
+
if self._current_popup and self._current_popup.isVisible():
|
|
1053
|
+
self._current_popup.close()
|
|
1054
|
+
self._current_popup = None
|
|
1055
|
+
|
|
1056
|
+
if actor is None:
|
|
1057
|
+
self.update_status_display("No object picked.")
|
|
1058
|
+
return
|
|
1059
|
+
|
|
1060
|
+
if not hasattr(self.vtk_plotter, "picked_point") or self.vtk_plotter.picked_point is None:
|
|
1061
|
+
self.update_status_display("No object picked.")
|
|
1062
|
+
return
|
|
1063
|
+
|
|
1064
|
+
picked_point = np.asarray(self.vtk_plotter.picked_point, float)
|
|
1065
|
+
|
|
1066
|
+
# Identify actor by name
|
|
1067
|
+
picked_name = None
|
|
1068
|
+
for name, act in self.plotter.actors.items():
|
|
1069
|
+
if act == actor:
|
|
1070
|
+
picked_name = name
|
|
1071
|
+
break
|
|
1072
|
+
|
|
1073
|
+
if picked_name is None:
|
|
1074
|
+
self.update_status_display("Could not identify picked object.")
|
|
1075
|
+
return
|
|
1076
|
+
|
|
1077
|
+
# Extract kind from actor name: "{kind}_nodes"
|
|
1078
|
+
picked_kind = None
|
|
1079
|
+
for kind in KIND_SIZE:
|
|
1080
|
+
if picked_name == f"{kind}_nodes":
|
|
1081
|
+
picked_kind = kind
|
|
1082
|
+
break
|
|
1083
|
+
|
|
1084
|
+
if picked_kind is None:
|
|
1085
|
+
self.update_status_display(f"Unknown actor: {picked_name}")
|
|
1086
|
+
return
|
|
1087
|
+
|
|
1088
|
+
# Find closest node of that kind
|
|
1089
|
+
best_id, best_dist = None, float("inf")
|
|
1090
|
+
for mesh_id, elem in self.visualizer.actor_to_node.items():
|
|
1091
|
+
if elem["kind"] != picked_kind:
|
|
1092
|
+
continue
|
|
1093
|
+
pos = np.asarray(elem["position"], float)
|
|
1094
|
+
d = float(np.linalg.norm(pos - picked_point))
|
|
1095
|
+
if d < best_dist:
|
|
1096
|
+
best_dist = d
|
|
1097
|
+
best_id = mesh_id
|
|
1098
|
+
|
|
1099
|
+
if best_id is None:
|
|
1100
|
+
self.update_status_display(f"No {picked_kind} near pick point.")
|
|
1101
|
+
return
|
|
1102
|
+
|
|
1103
|
+
elem = self.visualizer.actor_to_node[best_id]
|
|
1104
|
+
self.highlight_actor(elem["mesh"])
|
|
1105
|
+
title = f"{elem['kind'].capitalize()}: {elem['name']}"
|
|
1106
|
+
self._current_popup = DocstringPopup(
|
|
1107
|
+
title,
|
|
1108
|
+
_docstring_to_markdown(elem.get("docstring")),
|
|
1109
|
+
self,
|
|
1110
|
+
on_close_callback=self.reset_picking_state,
|
|
1111
|
+
)
|
|
1112
|
+
self._current_popup.show()
|
|
1113
|
+
|
|
1114
|
+
def highlight_actor(self, mesh) -> None:
|
|
1115
|
+
"""
|
|
1116
|
+
Highlight *mesh* in pink, focus camera, and zoom.
|
|
1117
|
+
Adapted from ``MainWindow.highlight_actor`` in *repo_vis*.
|
|
1118
|
+
"""
|
|
1119
|
+
if not self.plotter:
|
|
1120
|
+
return
|
|
1121
|
+
self.reset_actor_appearances()
|
|
1122
|
+
self.reset_camera()
|
|
1123
|
+
|
|
1124
|
+
self.plotter.add_mesh(
|
|
1125
|
+
mesh,
|
|
1126
|
+
color="pink",
|
|
1127
|
+
show_edges=True,
|
|
1128
|
+
edge_color="white",
|
|
1129
|
+
line_width=3,
|
|
1130
|
+
pickable=False,
|
|
1131
|
+
show_scalar_bar=False,
|
|
1132
|
+
reset_camera=False,
|
|
1133
|
+
name="_kg_highlight",
|
|
1134
|
+
)
|
|
1135
|
+
self._current_picked_actor = self.plotter.actors.get("_kg_highlight")
|
|
1136
|
+
|
|
1137
|
+
self._original_camera_state = { # type: ignore[assignment]
|
|
1138
|
+
"position": self.plotter.camera.position,
|
|
1139
|
+
"focal_point": self.plotter.camera.focal_point,
|
|
1140
|
+
"view_up": self.plotter.camera.up,
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
self.plotter.camera.focal_point = np.array(mesh.center)
|
|
1144
|
+
self.plotter.camera.Zoom(ZOOM_FACTOR)
|
|
1145
|
+
self.plotter.render()
|
|
1146
|
+
|
|
1147
|
+
def reset_actor_appearances(self) -> None:
|
|
1148
|
+
"""Reset pick highlights and restore actor colours."""
|
|
1149
|
+
if self._current_picked_actor:
|
|
1150
|
+
self.plotter.remove_actor(self._current_picked_actor, reset_camera=False)
|
|
1151
|
+
self._current_picked_actor = None
|
|
1152
|
+
|
|
1153
|
+
for kind in KIND_SIZE:
|
|
1154
|
+
actor = self.plotter.actors.get(f"{kind}_nodes")
|
|
1155
|
+
if actor:
|
|
1156
|
+
actor.prop.color = KIND_COLOR[kind]
|
|
1157
|
+
actor.prop.show_edges = False
|
|
1158
|
+
actor.prop.line_width = 1
|
|
1159
|
+
|
|
1160
|
+
_remove_highlight_actors(self.plotter)
|
|
1161
|
+
|
|
1162
|
+
def reset_picking_state(self) -> None:
|
|
1163
|
+
"""Clear pick state after popup is closed."""
|
|
1164
|
+
self.reset_actor_appearances()
|
|
1165
|
+
self.module_selector.clearSelection()
|
|
1166
|
+
self.update_status_display("Ready")
|
|
1167
|
+
self.plotter.render()
|
|
1168
|
+
|
|
1169
|
+
# ── Docstring button ────────────────────────────────────────────────────
|
|
1170
|
+
|
|
1171
|
+
def show_selected_docstring(self) -> None:
|
|
1172
|
+
"""Show docstring for the selected module in the list (if any)."""
|
|
1173
|
+
items = self.module_selector.selectedItems()
|
|
1174
|
+
if not items:
|
|
1175
|
+
self.update_status_display("Select a module first.")
|
|
1176
|
+
return
|
|
1177
|
+
mod_name = items[0].text()
|
|
1178
|
+
mod_node = next(
|
|
1179
|
+
(n for n in self.visualizer.nodes if n.kind == "module" and n.name == mod_name),
|
|
1180
|
+
None,
|
|
1181
|
+
)
|
|
1182
|
+
if mod_node is None:
|
|
1183
|
+
return
|
|
1184
|
+
self._current_popup = DocstringPopup(
|
|
1185
|
+
f"Module: {mod_name}",
|
|
1186
|
+
_docstring_to_markdown(mod_node.docstring),
|
|
1187
|
+
self,
|
|
1188
|
+
on_close_callback=self.reset_picking_state,
|
|
1189
|
+
)
|
|
1190
|
+
self._current_popup.show()
|
|
1191
|
+
|
|
1192
|
+
# ── Camera controls (spin_camera copied verbatim from repo_vis) ─────────
|
|
1193
|
+
|
|
1194
|
+
def reset_camera(self) -> None:
|
|
1195
|
+
"""Reset the camera to isometric view fitted to scene bounds."""
|
|
1196
|
+
if not self.plotter:
|
|
1197
|
+
return
|
|
1198
|
+
self.plotter.reset_camera()
|
|
1199
|
+
self.plotter.view_isometric()
|
|
1200
|
+
self.plotter.render()
|
|
1201
|
+
self.plotter.camera.zoom(3)
|
|
1202
|
+
self.visualizer.status = "View reset."
|
|
1203
|
+
|
|
1204
|
+
# ── Save ────────────────────────────────────────────────────────────────
|
|
1205
|
+
|
|
1206
|
+
def save_current_view(self) -> None:
|
|
1207
|
+
"""
|
|
1208
|
+
Save the current visualization to a file.
|
|
1209
|
+
Adapted from ``MainWindow.save_current_view`` in *repo_vis*.
|
|
1210
|
+
"""
|
|
1211
|
+
save_path = Path(self.visualizer.save_path)
|
|
1212
|
+
fmt = self.visualizer.save_format
|
|
1213
|
+
if save_path.suffix.lstrip(".") != fmt:
|
|
1214
|
+
save_path = save_path.with_suffix(f".{fmt}")
|
|
1215
|
+
|
|
1216
|
+
self.visualizer.status = f"Saving to {save_path}…"
|
|
1217
|
+
QApplication.processEvents()
|
|
1218
|
+
|
|
1219
|
+
try:
|
|
1220
|
+
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1221
|
+
if fmt == "html":
|
|
1222
|
+
self.plotter.export_html(str(save_path))
|
|
1223
|
+
else:
|
|
1224
|
+
self.plotter.screenshot(str(save_path))
|
|
1225
|
+
self.visualizer.status = f"Saved → {save_path}"
|
|
1226
|
+
except ImportError as exc:
|
|
1227
|
+
self.visualizer.status = f"HTML export unavailable: {exc}"
|
|
1228
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
1229
|
+
self.visualizer.status = f"Error saving: {exc}"
|
|
1230
|
+
|
|
1231
|
+
# ── Status display (mirrors repo_vis update_status_display) ────────────
|
|
1232
|
+
|
|
1233
|
+
def on_status_change(self, event: param.Event) -> None:
|
|
1234
|
+
"""Forward param status change to the Qt signal."""
|
|
1235
|
+
self.status_changed.emit(event.new)
|
|
1236
|
+
QApplication.processEvents()
|
|
1237
|
+
|
|
1238
|
+
def update_status_display(self, status: str) -> None:
|
|
1239
|
+
"""Render status text with colour coding (mirrors repo_vis)."""
|
|
1240
|
+
if status.startswith("Error"):
|
|
1241
|
+
html = f"<span style='color:#E53935;font-size:13px;'><b>Error:</b> {status[6:]}</span>"
|
|
1242
|
+
elif "Rendering" in status or "Loading" in status or "Saving" in status:
|
|
1243
|
+
html = f"<span style='color:#42A5F5;font-size:13px;'><b>⏳ {status}</b></span>"
|
|
1244
|
+
elif "Loaded" in status or "Saved" in status or "complete" in status.lower():
|
|
1245
|
+
html = f"<span style='color:#66BB6A;font-size:13px;'><b>✓ {status}</b></span>"
|
|
1246
|
+
elif "Ready" in status or "reset" in status.lower():
|
|
1247
|
+
html = "<span style='color:#66BB6A;font-size:13px;'><b>⚡ Ready</b></span>"
|
|
1248
|
+
else:
|
|
1249
|
+
html = f"<span style='font-size:13px;'>{status}</span>"
|
|
1250
|
+
self.status_display.setText(html)
|
|
1251
|
+
|
|
1252
|
+
# ── Reset settings ──────────────────────────────────────────────────────
|
|
1253
|
+
|
|
1254
|
+
def reset_settings(self) -> None:
|
|
1255
|
+
"""Reset all controls to defaults and clear the scene."""
|
|
1256
|
+
self.module_selector.clearSelection()
|
|
1257
|
+
self.cb_methods.setChecked(True)
|
|
1258
|
+
self.cb_symbols.setChecked(False)
|
|
1259
|
+
self.cb_contains.setChecked(False)
|
|
1260
|
+
self.cb_calls.setChecked(True)
|
|
1261
|
+
self.cb_imports.setChecked(True)
|
|
1262
|
+
self.cb_inherits.setChecked(True)
|
|
1263
|
+
self.reset_camera()
|
|
1264
|
+
self.visualizer.status = "Ready"
|
|
1265
|
+
|
|
1266
|
+
# ── Cleanup / close (copied from repo_vis) ──────────────────────────────
|
|
1267
|
+
|
|
1268
|
+
def cleanup_pyvista_objects(self) -> None:
|
|
1269
|
+
"""Thorough PyVista cleanup to prevent errors on exit."""
|
|
1270
|
+
if self._current_popup and hasattr(self._current_popup, "isVisible"):
|
|
1271
|
+
try:
|
|
1272
|
+
self._current_popup.close()
|
|
1273
|
+
except (AttributeError, RuntimeError):
|
|
1274
|
+
pass
|
|
1275
|
+
self._current_popup = None
|
|
1276
|
+
|
|
1277
|
+
if hasattr(self, "plotter") and self.plotter:
|
|
1278
|
+
try:
|
|
1279
|
+
if hasattr(self.plotter, "clear_actors"):
|
|
1280
|
+
self.plotter.clear_actors()
|
|
1281
|
+
if hasattr(self.plotter, "clear"):
|
|
1282
|
+
self.plotter.clear()
|
|
1283
|
+
if hasattr(self.plotter, "close"):
|
|
1284
|
+
self.plotter.close()
|
|
1285
|
+
self.visualizer.plotter = None
|
|
1286
|
+
self.plotter = None
|
|
1287
|
+
self.vtk_plotter = None
|
|
1288
|
+
except (AttributeError, RuntimeError):
|
|
1289
|
+
pass
|
|
1290
|
+
gc.collect()
|
|
1291
|
+
|
|
1292
|
+
def closeEvent(self, event) -> None:
|
|
1293
|
+
"""Handle window close with cleanup."""
|
|
1294
|
+
with warnings.catch_warnings():
|
|
1295
|
+
warnings.simplefilter("ignore")
|
|
1296
|
+
try:
|
|
1297
|
+
self.cleanup_pyvista_objects()
|
|
1298
|
+
except (AttributeError, RuntimeError):
|
|
1299
|
+
pass
|
|
1300
|
+
event.accept()
|
|
1301
|
+
|
|
1302
|
+
def run(self) -> None:
|
|
1303
|
+
"""Start the Qt event loop and show the window."""
|
|
1304
|
+
app = QApplication.instance() or QApplication(sys.argv)
|
|
1305
|
+
self.show()
|
|
1306
|
+
try:
|
|
1307
|
+
app.exec()
|
|
1308
|
+
except (RuntimeError, AttributeError) as exc:
|
|
1309
|
+
logger.error("Application error: %s", exc)
|
|
1310
|
+
finally:
|
|
1311
|
+
sys.exit()
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
# ---------------------------------------------------------------------------
|
|
1315
|
+
# Atexit cleanup
|
|
1316
|
+
# ---------------------------------------------------------------------------
|
|
1317
|
+
|
|
1318
|
+
atexit.register(gc.collect)
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
# ---------------------------------------------------------------------------
|
|
1322
|
+
# launch() — convenience entry point used by pycodekg_viz3d.py CLI
|
|
1323
|
+
# ---------------------------------------------------------------------------
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def launch(
|
|
1327
|
+
db_path: str = DEFAULT_DB,
|
|
1328
|
+
layout_name: str = "allium",
|
|
1329
|
+
width: int = 1400,
|
|
1330
|
+
height: int = 900,
|
|
1331
|
+
**_kwargs,
|
|
1332
|
+
) -> None:
|
|
1333
|
+
"""
|
|
1334
|
+
Create a :class:`QApplication`, open :class:`MainWindow`, and run the event loop.
|
|
1335
|
+
|
|
1336
|
+
:param db_path: Path to the SQLite database.
|
|
1337
|
+
:param layout_name: ``"allium"`` or ``"cake"``.
|
|
1338
|
+
:param width: Initial window width.
|
|
1339
|
+
:param height: Initial window height.
|
|
1340
|
+
"""
|
|
1341
|
+
app = QApplication.instance() or QApplication(sys.argv)
|
|
1342
|
+
app.setApplicationName("pycodekg-viz3d")
|
|
1343
|
+
|
|
1344
|
+
win = MainWindow(
|
|
1345
|
+
db_path=db_path,
|
|
1346
|
+
save_path=Path(db_path).stem,
|
|
1347
|
+
width=width,
|
|
1348
|
+
height=height,
|
|
1349
|
+
)
|
|
1350
|
+
win.visualizer.layout_name = layout_name
|
|
1351
|
+
win.layout_select.setCurrentText(layout_name)
|
|
1352
|
+
win.show()
|
|
1353
|
+
sys.exit(app.exec())
|