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.
Files changed (63) hide show
  1. pycode_kg/.DS_Store +0 -0
  2. pycode_kg/__init__.py +91 -0
  3. pycode_kg/__main__.py +11 -0
  4. pycode_kg/analysis/__init__.py +15 -0
  5. pycode_kg/analysis/bridge.py +108 -0
  6. pycode_kg/analysis/centrality.py +412 -0
  7. pycode_kg/analysis/framework_detector.py +103 -0
  8. pycode_kg/analysis/hybrid_rank.py +53 -0
  9. pycode_kg/app.py +1335 -0
  10. pycode_kg/architecture.py +624 -0
  11. pycode_kg/build_pycodekg_lancedb.py +15 -0
  12. pycode_kg/build_pycodekg_sqlite.py +15 -0
  13. pycode_kg/cli/__init__.py +29 -0
  14. pycode_kg/cli/cmd_analyze.py +96 -0
  15. pycode_kg/cli/cmd_architecture.py +150 -0
  16. pycode_kg/cli/cmd_bridges.py +26 -0
  17. pycode_kg/cli/cmd_build.py +154 -0
  18. pycode_kg/cli/cmd_build_full.py +242 -0
  19. pycode_kg/cli/cmd_centrality.py +131 -0
  20. pycode_kg/cli/cmd_explain.py +180 -0
  21. pycode_kg/cli/cmd_framework_nodes.py +18 -0
  22. pycode_kg/cli/cmd_hooks.py +137 -0
  23. pycode_kg/cli/cmd_init.py +312 -0
  24. pycode_kg/cli/cmd_mcp.py +71 -0
  25. pycode_kg/cli/cmd_model.py +53 -0
  26. pycode_kg/cli/cmd_query.py +211 -0
  27. pycode_kg/cli/cmd_snapshot.py +421 -0
  28. pycode_kg/cli/cmd_viz.py +180 -0
  29. pycode_kg/cli/main.py +23 -0
  30. pycode_kg/cli/options.py +63 -0
  31. pycode_kg/config.py +78 -0
  32. pycode_kg/graph.py +125 -0
  33. pycode_kg/index.py +542 -0
  34. pycode_kg/kg.py +220 -0
  35. pycode_kg/layout3d.py +470 -0
  36. pycode_kg/mcp/bridge_tools.py +19 -0
  37. pycode_kg/mcp/framework_tools.py +18 -0
  38. pycode_kg/mcp_server.py +1965 -0
  39. pycode_kg/module/__init__.py +83 -0
  40. pycode_kg/module/base.py +720 -0
  41. pycode_kg/module/extractor.py +276 -0
  42. pycode_kg/module/types.py +532 -0
  43. pycode_kg/pycodekg.py +543 -0
  44. pycode_kg/pycodekg_query.py +1 -0
  45. pycode_kg/pycodekg_snippet_packer.py +1 -0
  46. pycode_kg/pycodekg_thorough_analysis.py +2751 -0
  47. pycode_kg/pycodekg_viz.py +1 -0
  48. pycode_kg/pycodekg_viz3d.py +1 -0
  49. pycode_kg/ranking/__init__.py +1 -0
  50. pycode_kg/ranking/cli_rank.py +92 -0
  51. pycode_kg/ranking/coderank.py +555 -0
  52. pycode_kg/snapshots.py +612 -0
  53. pycode_kg/sql/004_add_centrality_table.sql +12 -0
  54. pycode_kg/store.py +766 -0
  55. pycode_kg/utils.py +39 -0
  56. pycode_kg/visitor.py +413 -0
  57. pycode_kg/viz3d.py +1353 -0
  58. pycode_kg/viz3d_timeline.py +364 -0
  59. pycode_kg-0.16.0.dist-info/METADATA +305 -0
  60. pycode_kg-0.16.0.dist-info/RECORD +63 -0
  61. pycode_kg-0.16.0.dist-info/WHEEL +4 -0
  62. pycode_kg-0.16.0.dist-info/entry_points.txt +19 -0
  63. 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())