Skip to content

3-D visualizer

gutenberg_kg.viz3d

viz3d.py — 3-D Knowledge Tree Forest visualiser for GutenbergKG.

Each book is rendered as a tree: - Trunk → document node (brown cylinder, height ∝ log(chunk_count)) - Branches → section nodes (forest-green spheres radiating from trunk apex) - Leaves → chunk nodes (light-green spheres clustered around each section) - Spores → entity / topic nodes (gold dots floating above the canopy)

Books are grouped by genre into groves, with genres arranged in a large Fibonacci annulus so the whole corpus forms a navigable 3-D forest.

This module is the Qt viewer only: corpus scanning, layout, and scene composition live in :mod:gutenberg_kg.scene, which knows nothing about Qt so the off-screen light-field renderer (gutenkg quilt) can share them.

Requires: pyvista, pyvistaqt, PyQt5, param, numpy, and the shared 3-D layout primitives from kg_utils.viz3d (kgmodule-utils' viz3d extra).

Author: Eric G. Suchanek, PhD

ForestMainWindow(corpus_root=DEFAULT_CORPUS, save_path=DEFAULT_SAVE, width=1500, height=950)

Bases: QMainWindow

Qt main window for the Gutenberg 3-D forest visualiser.

Left panel — corpus path, genre selector, book selector, render options. Right panel — PyVista QtInteractor + button row.

Parameters:

Name Type Description Default
corpus_root str

Path to the corpus directory.

DEFAULT_CORPUS
save_path str

Default output file stem for exports.

DEFAULT_SAVE
width int

Initial window width in pixels.

1500
height int

Initial window height in pixels.

950

Build the main window: control/viewport panels, mesh picking, and signal wiring.

Source code in src/gutenberg_kg/viz3d.py
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
def __init__(
    self,
    corpus_root: str = DEFAULT_CORPUS,
    save_path: str = DEFAULT_SAVE,
    width: int = 1500,
    height: int = 950,
) -> None:
    """Build the main window: control/viewport panels, mesh picking, and signal wiring."""
    super().__init__()
    self.timer = None
    self._current_picked_actor = None
    self._current_popup: TextPopup | None = None

    self.setGeometry(100, 100, width, height)

    self.vtk_plotter: QtInteractor = QtInteractor(self)
    self.visualizer: GutenbergForestVisualizer = GutenbergForestVisualizer(
        plotter=self.vtk_plotter,
        corpus_root=corpus_root,
        save_path=save_path,
    )
    self.plotter = self.vtk_plotter

    self.setWindowTitle(self.visualizer.window_title)

    central = QWidget()
    self.setCentralWidget(central)
    main_layout = QHBoxLayout(central)

    self.setStyleSheet(DARK_STYLESHEET)

    ctrl_widget = self._build_control_panel()
    vis_widget = self._build_viewport_panel()

    # The panel is taller than the window on a laptop display, and the
    # action buttons live at the bottom of it — so without this the Render
    # and Cast buttons are simply unreachable rather than merely cramped.
    ctrl_scroll = QScrollArea()
    ctrl_scroll.setWidget(ctrl_widget)
    ctrl_scroll.setWidgetResizable(True)
    ctrl_scroll.setFrameShape(QFrame.NoFrame)
    ctrl_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
    ctrl_scroll.setFixedWidth(CONTROL_PANEL_WIDTH + SCROLLBAR_ALLOWANCE)

    main_layout.addWidget(ctrl_scroll)
    main_layout.addWidget(vis_widget, stretch=1)
    main_layout.setContentsMargins(0, 0, 0, 0)
    main_layout.setSpacing(4)

    ctrl_scroll.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)

    self._setup_mesh_picking()
    self._connect_signals()

    self.setFont(QFont("Arial", 12))
    self.resize(width, height)

cast_povray()

Ray-trace a full light-field quilt and push it to the Looking Glass.

The same scene the preview traced, at the Looking Glass preset instead of a single view — so this is :data:POV_PREVIEW_SIZE arithmetic times n_views, which is minutes rather than seconds. The quilt is written to renders/pov/ before Bridge is contacted, so a failed cast still leaves the hologram file behind.

Source code in src/gutenberg_kg/viz3d.py
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
def cast_povray(self) -> None:
    """
    Ray-trace a full light-field quilt and push it to the Looking Glass.

    The same scene the preview traced, at the Looking Glass preset instead
    of a single view — so this is :data:`POV_PREVIEW_SIZE` arithmetic times
    ``n_views``, which is minutes rather than seconds.  The quilt is
    written to ``renders/pov/`` before Bridge is contacted, so a failed
    cast still leaves the hologram file behind.
    """
    composed = self._build_pov_scene()
    if composed is None:
        return
    scene, geometry, slug = composed
    self._pov_geometry = geometry

    out_dir = Path(self.visualizer.corpus_root).parent / "renders" / "pov"
    out_dir.mkdir(parents=True, exist_ok=True)
    self._pov_stem = out_dir / f"{slug}_quilt"
    pov_path = scene.write(self._pov_stem.with_suffix(".pov"))

    from quiltwright import QUILT_PRESETS
    from quiltwright.povgen import pov_camera_from_plotter

    spec = QUILT_PRESETS[QUILT_SPEC].scaled(CAST_SCALE)
    camera = pov_camera_from_plotter(self.vtk_plotter, handedness=scene.handedness)
    self.visualizer.status = f"Wrote {pov_path.name}; ray-tracing {spec.n_views} views..."
    self._pov_session.start(
        pov_path, spec, camera, on_image=self._cast_pov_result, label="quilt"
    )

cast_to_looking_glass()

Render the current view as a quilt and push it to the Looking Glass.

Re-composes the same scene into an off-screen plotter and copies this window's camera, so what is cast is the view being looked at — then hands the quilt to Bridge. Needs Looking Glass Bridge running on this machine; the render is kept either way.

The quilt is rendered at :data:CAST_SCALE of the preset's pixel size: the local render costs about a second at full size, but the wait is Bridge loading the resulting PNG, and that scales with its area.

Source code in src/gutenberg_kg/viz3d.py
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
def cast_to_looking_glass(self) -> None:
    """
    Render the current view as a quilt and push it to the Looking Glass.

    Re-composes the same scene into an off-screen plotter and copies this
    window's camera, so what is cast is the view being looked at — then
    hands the quilt to Bridge.  Needs Looking Glass Bridge running on this
    machine; the render is kept either way.

    The quilt is rendered at :data:`CAST_SCALE` of the preset's pixel size:
    the local render costs about a second at full size, but the wait is
    Bridge loading the resulting PNG, and that scales with its area.
    """
    try:
        from quiltwright import QUILT_PRESETS
    except ImportError:
        self.visualizer.status = "Casting needs quiltwright: pip install gutenberg-kg[viz3d]"
        return
    if not self.visualizer.all_nodes:
        self.visualizer.status = "Nothing to cast — render something first."
        return

    if self.visualizer.render_style == RENDER_STYLE_POVRAY:
        self.cast_povray()
        return

    spec = QUILT_PRESETS[QUILT_SPEC].scaled(CAST_SCALE)

    def step(n: int, total: int, message: str) -> None:
        """Report cast progress, pumping the event loop so it is visible."""
        self.visualizer.status = f"Cast {n}/{total} — {message}"
        QApplication.processEvents()

    # camera_position is (position, focal point, view up) and carries no
    # view angle, so the off-screen plotter would keep VTK's default 30
    # while this window sits at the RENDER_FOV that Frame for Render set.
    # The subject then lands tan(15)/tan(7) = 2.2x too small -- and Frame
    # for Render is the documented step before casting, so that was the
    # normal path. create_forest_visualization does not set the angle, so
    # the builder carries it across; the helper assigns camera_position
    # afterwards, which does not disturb it.
    view_angle = self.vtk_plotter.camera.view_angle

    def build(offscreen) -> None:
        """Compose the scene off-screen at this window's framing.

        :param offscreen: The off-screen plotter to compose into.
        """
        create_forest_visualization(self.visualizer, offscreen)
        offscreen.camera.view_angle = view_angle

    out_dir = Path(self.visualizer.corpus_root).parent / "renders" / "quilts"
    self.cast_btn.setEnabled(False)
    try:
        result = cast_scene_to_looking_glass(
            build,
            self.vtk_plotter.camera_position,
            out_dir / f"{Path(self.visualizer.save_path).name}_cast",
            spec,
            progress=step,
        )
        if result.path is None:
            logger.error("Cast failed: %s", result.error)
        self.visualizer.status = result.message
    finally:
        self.cast_btn.setEnabled(True)

cleanup()

Close any open popup, stop a running trace, and tear down the plotter.

Order matters: the render session goes first. A QThread still running when the window is destroyed aborts the process, and a queued signal delivered after these widgets are gone raises from inside Qt — so the session is detached before anything it touches is torn down.

Source code in src/gutenberg_kg/viz3d.py
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
def cleanup(self) -> None:
    """Close any open popup, stop a running trace, and tear down the plotter.

    Order matters: the render session goes first.  A ``QThread`` still
    running when the window is destroyed aborts the process, and a queued
    signal delivered after these widgets are gone raises from inside Qt —
    so the session is detached before anything it touches is torn down.
    """
    self._pov_session.shutdown()
    if self._current_popup and hasattr(self._current_popup, "isVisible"):
        try:
            self._current_popup.close()
        except Exception:  # noqa: BLE001
            pass
    if self.plotter:
        try:
            self.plotter.clear_actors()
            self.plotter.close()
            self.visualizer.plotter = None
            self.plotter = None
            self.vtk_plotter = None
        except Exception:  # noqa: BLE001
            pass
    gc.collect()

closeEvent(event)

Qt window-close handler: run :meth:cleanup (warnings suppressed) then accept.

Source code in src/gutenberg_kg/viz3d.py
1507
1508
1509
1510
1511
1512
1513
1514
1515
def closeEvent(self, event) -> None:
    """Qt window-close handler: run :meth:`cleanup` (warnings suppressed) then accept."""
    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        try:
            self.cleanup()
        except Exception:  # noqa: BLE001
            pass
    event.accept()

frame_for_render()

Put the viewport camera exactly where the renderers will put theirs.

Reset View frames whatever actors are on screen, ground slab and all. This frames the subject: :func:kg_utils.viz3d.frame_tree is the one rule gutenkg pov and gutenkg quilt also use, so after pressing this the viewport is a true preview of the render — which is what makes the depth budget on a light-field panel predictable rather than a matter of nudging the mouse.

Falls back to the plotter's bounds when no tree has been grown yet, the same fallback :func:tree_pov_camera makes.

Source code in src/gutenberg_kg/viz3d.py
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
def frame_for_render(self) -> None:
    """
    Put the viewport camera exactly where the renderers will put theirs.

    ``Reset View`` frames whatever actors are on screen, ground slab and
    all.  This frames the *subject*: :func:`kg_utils.viz3d.frame_tree` is
    the one rule ``gutenkg pov`` and ``gutenkg quilt`` also use, so after
    pressing this the viewport is a true preview of the render — which is
    what makes the depth budget on a light-field panel predictable rather
    than a matter of nudging the mouse.

    Falls back to the plotter's bounds when no tree has been grown yet,
    the same fallback :func:`tree_pov_camera` makes.
    """
    if not self.plotter:
        return
    from gutenberg_kg.povscene import tree_camera_frame

    geometry = getattr(self, "_pov_geometry", None)
    bounds = None
    if geometry is None:
        raw = self.plotter.bounds
        if raw is None:
            self.visualizer.status = "Nothing to frame — render something first."
            return
        bounds = (
            np.array([raw[0], raw[2], raw[4]], dtype=float),
            np.array([raw[1], raw[3], raw[5]], dtype=float),
        )
    try:
        frame = tree_camera_frame(geometry, bounds=bounds, fov=RENDER_FOV)
    except ValueError as exc:
        self.visualizer.status = f"Cannot frame: {exc}"
        return

    self.plotter.camera_position = [frame.position, frame.focal_point, frame.up]
    self.plotter.camera.view_angle = RENDER_FOV
    self.plotter.render()
    self.visualizer.status = (
        f"Framed for render at {RENDER_FOV:.0f}° FOV"
        f"{'' if geometry is not None else ' (from scene bounds)'}."
    )

on_pick(actor)

Right-click callback: find nearest node and show text popup.

Source code in src/gutenberg_kg/viz3d.py
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
def on_pick(self, actor) -> None:
    """Right-click callback: find nearest node and show text popup."""
    if not self.visualizer.plotter:
        return
    self._clear_highlight()
    if self._current_popup and self._current_popup.isVisible():
        self._current_popup.close()
        self._current_popup = None
    if actor is None:
        self.update_status_display("Right-click a node sphere or trunk to inspect.")
        return
    if not hasattr(self.vtk_plotter, "picked_point") or self.vtk_plotter.picked_point is None:
        self.update_status_display("No pick point — zoom in and right-click closer to a node.")
        return

    picked_point = np.asarray(self.vtk_plotter.picked_point, float)

    # Identify actor name and map to a node kind
    picked_kind: str | None = None
    for name, act in self.plotter.actors.items():
        if act != actor:
            continue
        if name == "trunks":
            # Trunk click → find nearest document node (shows book info)
            picked_kind = "document"
        else:
            for kind in KIND_SIZE:
                if name == f"{kind}_nodes":
                    picked_kind = kind
                    break
        break

    if picked_kind is None:
        self.update_status_display("Click on a node sphere or trunk cylinder.")
        return

    # Find closest node of that kind
    best_id, best_dist = None, float("inf")
    for mesh_id, elem in self.visualizer.actor_to_node.items():
        if elem["kind"] != picked_kind:
            continue
        d = float(np.linalg.norm(np.asarray(elem["position"], float) - picked_point))
        if d < best_dist:
            best_dist = d
            best_id = mesh_id

    if best_id is None:
        self.update_status_display(f"No {picked_kind} node near pick point.")
        return

    elem = self.visualizer.actor_to_node[best_id]
    # Build highlight mesh at the picked node position (no per-node mesh stored)
    pos = np.asarray(elem["position"], float)
    highlight = make_node_mesh(elem["kind"], pos, KIND_SIZE[elem["kind"]] * 1.5, "high")
    self._highlight_mesh(highlight)

    kind_label = elem["kind"].capitalize()
    raw_name = elem["name"]
    title = f"{kind_label}: {raw_name}"
    text = elem.get("docstring") or f"**{raw_name}**\n\n_{kind_label} node._"
    self._current_popup = TextPopup(title, text, self, on_close_callback=self._on_popup_close)
    self._current_popup.show()
    self.update_status_display(f"Picked: {title} (dist {best_dist:.1f})")

on_render_clicked()

Render button handler: drive whichever backend the style names.

Source code in src/gutenberg_kg/viz3d.py
1038
1039
1040
1041
1042
1043
def on_render_clicked(self) -> None:
    """Render button handler: drive whichever backend the style names."""
    if self.visualizer.render_style == RENDER_STYLE_POVRAY:
        self.render_povray()
    else:
        self.visualizer.visualize()

on_status_change(event)

param watcher for visualizer.status: re-emit as the Qt status_changed signal.

Source code in src/gutenberg_kg/viz3d.py
1442
1443
1444
1445
def on_status_change(self, event: param.Event) -> None:
    """``param`` watcher for ``visualizer.status``: re-emit as the Qt ``status_changed`` signal."""
    self.status_changed.emit(event.new)
    QApplication.processEvents()

render_povray()

Ray-trace the loaded book and show the result.

Loads if needed, writes the .pov beside the other renders, then hands the trace to :class:PovRenderWorker so the window stays responsive. The camera comes from the viewport, so what is traced is what is being looked at — use Frame for Render first to put the viewport where the quilt would be.

Source code in src/gutenberg_kg/viz3d.py
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
def render_povray(self) -> None:
    """
    Ray-trace the loaded book and show the result.

    Loads if needed, writes the ``.pov`` beside the other renders, then
    hands the trace to :class:`PovRenderWorker` so the window stays
    responsive.  The camera comes from the viewport, so what is traced is
    what is being looked at — use **Frame for Render** first to put the
    viewport where the quilt would be.
    """
    if not self.visualizer.all_nodes:
        self.visualizer.load_selected()
    composed = self._build_pov_scene()
    if composed is None:
        return
    scene, geometry, slug = composed
    self._pov_geometry = geometry

    out_dir = Path(self.visualizer.corpus_root).parent / "renders" / "pov"
    out_dir.mkdir(parents=True, exist_ok=True)
    self._pov_stem = out_dir / slug
    pov_path = scene.write(self._pov_stem.with_suffix(".pov"))
    self.visualizer.status = f"Wrote {pov_path.name} ({pov_path.stat().st_size / 1024:.0f} KB)"
    QApplication.processEvents()

    from quiltwright.povgen import pov_camera_from_plotter

    from gutenberg_kg.povscene import preview_spec

    camera = pov_camera_from_plotter(self.vtk_plotter, handedness=scene.handedness)
    spec = preview_spec(*POV_PREVIEW_SIZE)
    self._pov_session.start(
        pov_path,
        spec,
        camera,
        on_image=self._preview_pov_result,
        label="preview",
        antialias=POV_PREVIEW_ANTIALIAS,
    )

reset_camera()

Reset the plotter camera to the default isometric view and zoom level.

Source code in src/gutenberg_kg/viz3d.py
1393
1394
1395
1396
1397
1398
1399
1400
def reset_camera(self) -> None:
    """Reset the plotter camera to the default isometric view and zoom level."""
    if not self.plotter:
        return
    self.plotter.reset_camera()  # type: ignore[call-arg]
    self.plotter.view_isometric()  # type: ignore[call-arg]
    self.plotter.render()
    self.plotter.camera.zoom(2)

reset_settings()

Reset Settings button handler: clear selections, restore default visibility checkboxes, and reset the camera.

Source code in src/gutenberg_kg/viz3d.py
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
def reset_settings(self) -> None:
    """Reset Settings button handler: clear selections, restore default visibility
    checkboxes, and reset the camera."""
    self.genre_selector.clearSelection()
    self.book_selector.clearSelection()
    self.cb_organic.setChecked(False)
    self.season_selector.setCurrentText(DEFAULT_SEASON)
    self.cb_sections.setChecked(True)
    self.cb_chunks.setChecked(True)
    self.cb_entities.setChecked(False)
    self.cb_contains.setChecked(True)
    self.cb_similar.setChecked(False)
    self.cb_next.setChecked(False)
    self.reset_camera()
    self.visualizer.status = "Ready"

save_current_view()

Save View button handler: export the current scene to HTML or a screenshot.

Source code in src/gutenberg_kg/viz3d.py
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
def save_current_view(self) -> None:
    """Save View button handler: export the current scene to HTML or a screenshot."""
    save_path = Path(self.visualizer.save_path)
    fmt = self.visualizer.save_format
    if save_path.suffix.lstrip(".") != fmt:
        save_path = save_path.with_suffix(f".{fmt}")
    self.visualizer.status = f"Saving → {save_path}…"
    QApplication.processEvents()
    try:
        save_path.parent.mkdir(parents=True, exist_ok=True)
        if fmt == "html":
            self.plotter.export_html(str(save_path))
        else:
            self.plotter.screenshot(str(save_path))
        self.visualizer.status = f"Saved → {save_path}"
    except (OSError, RuntimeError, ImportError) as exc:
        self.visualizer.status = f"Error saving: {exc}"

show_selected_text()

Show text for the first selected book in the book selector.

Source code in src/gutenberg_kg/viz3d.py
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
def show_selected_text(self) -> None:
    """Show text for the first selected book in the book selector."""
    items = self.book_selector.selectedItems()
    if not items:
        self.update_status_display("Select a book first.")
        return
    book_title = items[0].text()
    self._current_popup = TextPopup(
        book_title,
        f"**{book_title}**\n\nSelect a node in the scene to view its text.",
        self,
        on_close_callback=self._on_popup_close,
    )
    self._current_popup.show()

update_status_display(status)

Render status into the status label, color-coded by message type (error/busy/done).

Source code in src/gutenberg_kg/viz3d.py
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
def update_status_display(self, status: str) -> None:
    """Render *status* into the status label, color-coded by message type (error/busy/done)."""
    if status.startswith("Error"):
        html = f"<span style='color:#FF6B6B;font-size:13px;'><b>{status}</b></span>"
    elif any(kw in status for kw in ("Rendering", "Loading", "Building", "Drawing")):
        html = f"<span style='color:#87CEEB;font-size:13px;'><b>⏳ {status}</b></span>"
    elif any(kw in status for kw in ("rendered", "Saved", "Loaded", "Corpus")):
        html = f"<span style='color:#90EE90;font-size:13px;'><b>✓ {status}</b></span>"
    else:
        html = f"<span style='color:#c0c0c0;font-size:13px;'>{status}</span>"
    self.status_display.setText(html)

update_window_title(event)

param watcher for visualizer.window_title: apply it to the Qt window.

Source code in src/gutenberg_kg/viz3d.py
1459
1460
1461
def update_window_title(self, event: param.Event) -> None:
    """``param`` watcher for ``visualizer.window_title``: apply it to the Qt window."""
    self.setWindowTitle(event.new)

GutenbergForestVisualizer(plotter=None, **params)

Bases: Parameterized

Data and state model for the Gutenberg 3-D forest visualiser.

Reactive attributes (via param) drive the Qt control panel.

Parameters:

Name Type Description Default
corpus_root

Path to the corpus directory.

required
plotter Plotter | None

The QtInteractor to render into.

None

Initialise the visualiser data model.

Parameters:

Name Type Description Default
plotter Plotter | None

The QtInteractor to render into.

None
params

Additional param keyword arguments.

{}
Source code in src/gutenberg_kg/viz3d.py
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
def __init__(self, plotter: pv.Plotter | None = None, **params) -> None:
    """
    Initialise the visualiser data model.

    :param plotter: The ``QtInteractor`` to render into.
    :param params: Additional ``param`` keyword arguments.
    """
    super().__init__(**params)
    self.plotter: pv.Plotter | None = plotter

    # Book catalogue: {genre: [BookMeta]}
    self._catalogue: dict[str, list[BookMeta]] = {}
    # Loaded graph data (all books currently selected)
    self.all_nodes: list[LayoutNode] = []
    self.all_edges: list[LayoutEdge] = []
    # slug → genre mapping for ForestLayout
    self._book_genre_map: dict[str, str] = {}
    # entry document id → ISO timestamp, for dated (diary) books
    self._entry_times: dict[str, str] = {}

    self.actor_to_node: dict[str, dict] = {}
    self._load_catalogue()

load_selected()

Load nodes and edges for the currently selected genres/books. Populates :attr:all_nodes, :attr:all_edges, :attr:_book_genre_map.

Source code in src/gutenberg_kg/viz3d.py
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
def load_selected(self) -> None:
    """
    Load nodes and edges for the currently selected genres/books.
    Populates :attr:`all_nodes`, :attr:`all_edges`, :attr:`_book_genre_map`.
    """
    self.status = "Loading book graphs..."
    QApplication.processEvents()

    genres_to_load = self.selected_genres or list(self._catalogue.keys())
    books_filter = set(self.selected_books)

    self.all_nodes = []
    self.all_edges = []
    self._book_genre_map = {}
    self._entry_times = {}

    loaded = 0
    for genre in genres_to_load:
        for meta in self._catalogue.get(genre, []):
            if books_filter and meta.title not in books_filter:
                continue
            self.status = f"Loading: {meta.title}"
            QApplication.processEvents()
            try:
                nodes, edges = load_book_graph(meta)
                self.all_nodes.extend(nodes)
                self.all_edges.extend(edges)
                self._entry_times.update(load_entry_times(meta))
                self._book_genre_map[meta.slug] = genre
                loaded += 1
            except Exception as exc:  # noqa: BLE001
                logger.warning("Failed to load %s: %s", meta.title, exc)

    self.status = (
        f"Loaded {loaded} book(s): {len(self.all_nodes):,} nodes, {len(self.all_edges):,} edges"
    )

visualize()

Load selected books and render the 3-D forest.

Source code in src/gutenberg_kg/viz3d.py
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
def visualize(self) -> None:
    """Load selected books and render the 3-D forest."""
    if not self.plotter:
        return
    self.load_selected()
    if not self.all_nodes:
        self.status = "No books loaded — select a genre and render."
        return

    try:
        _, title, actor_to_node = create_forest_visualization(self, self.plotter)
        self.actor_to_node = actor_to_node
        self.window_title = title
    except (ValueError, RuntimeError) as exc:
        self.status = f"Error: {exc}"
        logger.exception("Render error")

TextPopup(title, text, parent=None, on_close_callback=None)

Bases: QDialog

Modeless popup that renders node text as Markdown HTML.

Parameters:

Name Type Description Default
title str

Window title.

required
text str

Raw text content.

required
parent

Parent widget.

None
on_close_callback

Called when the window closes.

None

Build the popup dialog: title bar, rendered Markdown browser, and Close button.

Source code in src/gutenberg_kg/viz3d.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
def __init__(self, title: str, text: str, parent=None, on_close_callback=None):
    """Build the popup dialog: title bar, rendered Markdown browser, and Close button."""
    super().__init__(parent)
    self.setWindowTitle(title)
    self.setMinimumSize(640, 420)
    self.on_close_callback = on_close_callback
    self.setWindowModality(Qt.NonModal)  # type: ignore[attr-defined]

    if parent:
        geo = parent.screen().geometry()
        self.move(geo.x() + 60, geo.y() + 60)

    layout = QVBoxLayout(self)
    html = markdown(text or "No text available.")
    browser = QTextBrowser(self)
    browser.setHtml(html)
    layout.addWidget(browser)

    close_btn = QPushButton("Close", self)
    close_btn.clicked.connect(self.close)  # type: ignore[arg-type]
    layout.addWidget(close_btn)

closeEvent(event)

Invoke the close callback, if any, then let the dialog close normally.

Source code in src/gutenberg_kg/viz3d.py
308
309
310
311
312
def closeEvent(self, event):
    """Invoke the close callback, if any, then let the dialog close normally."""
    if self.on_close_callback:
        self.on_close_callback()
    super().closeEvent(event)

create_forest_visualization(viz, plotter)

Render the knowledge tree forest into plotter for the Qt viewer.

Thin wrapper over :func:gutenberg_kg.scene.build_forest_scene: it maps the visualiser's param toggles onto :class:~gutenberg_kg.scene.SceneFilters and pumps the Qt event loop from the builder's progress callback so the status bar stays live during a long build.

Parameters:

Name Type Description Default
viz GutenbergForestVisualizer

:class:GutenbergForestVisualizer instance.

required
plotter Plotter

The QtInteractor to render into.

required

Returns:

Type Description
tuple[Plotter, str, dict[str, dict]]

(plotter, title_text, actor_to_node)

Source code in src/gutenberg_kg/viz3d.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
def create_forest_visualization(
    viz: GutenbergForestVisualizer,
    plotter: pv.Plotter,
) -> tuple[pv.Plotter, str, dict[str, dict]]:
    """
    Render the knowledge tree forest into *plotter* for the Qt viewer.

    Thin wrapper over :func:`gutenberg_kg.scene.build_forest_scene`: it maps
    the visualiser's ``param`` toggles onto :class:`~gutenberg_kg.scene.SceneFilters`
    and pumps the Qt event loop from the builder's progress callback so the
    status bar stays live during a long build.

    :param viz: :class:`GutenbergForestVisualizer` instance.
    :param plotter: The ``QtInteractor`` to render into.
    :return: ``(plotter, title_text, actor_to_node)``
    """

    def _progress(message: str) -> None:
        viz.status = message
        QApplication.processEvents()

    if viz.organic:
        slugs = {n.id.split(":")[0] for n in viz.all_nodes}
        if len(slugs) != 1:
            viz.status = f"Organic mode needs exactly one book selected ({len(slugs)} loaded)."
            return plotter, viz.window_title, {}
        slug = slugs.pop()
        tree: SceneInfo = build_tree_scene(
            viz.all_nodes,
            viz.all_edges,
            plotter,
            slug=slug,
            genre=viz._book_genre_map.get(slug, "unknown"),
            entry_times=viz._entry_times,
            # One checkbox drives both clouds here, so the viewer keeps the
            # behaviour it had before entities and topics were split apart.
            filters=SceneFilters(show_entities=viz.show_entities, show_topics=viz.show_entities),
            season=viz.season,
            progress=_progress,
        )
        plotter.reset_camera()  # type: ignore[call-arg]
        plotter.render()
        # Picking has nothing to hit: the organic scene is swept wood and
        # glyphed foliage, not one actor per node.
        return plotter, tree.title, {}

    info: SceneInfo = build_forest_scene(
        viz.all_nodes,
        viz.all_edges,
        plotter,
        book_genre_map=viz._book_genre_map,
        entry_times=viz._entry_times,
        filters=SceneFilters(
            show_sections=viz.show_sections,
            show_chunks=viz.show_chunks,
            show_entities=viz.show_entities,
            show_topics=viz.show_entities,
            show_contains=viz.show_contains,
            show_similar=viz.show_similar,
            show_next=viz.show_next,
        ),
        progress=_progress,
    )
    return plotter, info.title, info.actor_to_node

launch(corpus_root=DEFAULT_CORPUS, width=1500, height=950, **_kwargs)

Open the Gutenberg KG Forest window and run the Qt event loop.

Parameters:

Name Type Description Default
corpus_root str

Path to the corpus directory (corpus/ by default).

DEFAULT_CORPUS
width int

Initial window width in pixels.

1500
height int

Initial window height in pixels.

950
Source code in src/gutenberg_kg/viz3d.py
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
def launch(
    corpus_root: str = DEFAULT_CORPUS,
    width: int = 1500,
    height: int = 950,
    **_kwargs,
) -> None:
    """
    Open the Gutenberg KG Forest window and run the Qt event loop.

    :param corpus_root: Path to the corpus directory (``corpus/`` by default).
    :param width: Initial window width in pixels.
    :param height: Initial window height in pixels.
    """
    app = QApplication.instance() or QApplication(sys.argv)
    app.setApplicationName("gutenberg-kg-forest")

    win = ForestMainWindow(
        corpus_root=corpus_root,
        save_path=str(Path(corpus_root).parent / DEFAULT_SAVE),
        width=width,
        height=height,
    )
    win.show()
    sys.exit(app.exec())