Skip to content

visualdynamics.gui.main_window

main_window

Main application window: project tree, 3D/plot/table views, unit selector.

Classes:

Name Description
MainWindow

The app: a project tree, and panes that read whatever is picked.

Classes

MainWindow

MainWindow(offscreen_3d: bool = False)

Bases: QMainWindow

The app: a project tree, and panes that read whatever is picked.

The window holds a Project and nothing else — the same object a script builds, with the same verbs on it — so a project built by clicking and one built by calling are the same project and open in each other. Every button here is one of those verbs plus a sentence in the status bar; nothing is computed in this file that a script cannot compute without it.

What it adds is the reading: which pane suits what is selected, which of several ways to read it is wanted, and the editing of the things that are edited rather than computed — units, channel tables, geometry, the report.

offscreen_3d replaces the embedded VTK view with an off-screen plotter — VTK's Qt widget cannot run under Qt's offscreen platform, so headless tests use this to exercise the full render path.

Methods:

Name Description
set_playing

Start or stop the animation.

apply_theme

Adopt a light/dark theme (default: whatever the OS is set to).

closeEvent

Shut every VTK view down before Qt destroys the widgets.

add_object

Add an object, keeping names unique.

show_object

Give an object already in the project its row in the tree.

current_reference

(kind, object, index-or-component) for the selected tree item.

current_object

The object owning the selection (a child resolves to its parent).

selected_references

[(kind, name, object, detail)] for every selected tree item.

object_item

The object-level item for a selection (walks up to the test).

delete_selected

Remove what is selected: entities while editing or when entity

acts_for

[(verb, label, icon, handler, tooltip)] the selection can

refresh_selected

Recompute the selected stale object — the bar's Recompute,

generate_report_act

The bar's Generate Report: the typed report at once, or the

refresh_object

Recompute a stale object in place — the badge's click.

linked_group

The linked member names beside name, or None.

link_role

The role of name's link group — 'Basis', or None for an

set_link_role

Declare a link group the Basis of comparisons — explicit,

linked_geometry

(geometry name, geometry) linked with name, or None —

unlink_selected

Take the selected objects out of their groups; a group left

set_project_type

Declare what kind of test this project is — the tree then

delete_entity_rows

Delete the rows selected in the edit table.

define_units

Declare units for what is selected.

set_active_geometry

Choose the geometry every other object is checked against.

refresh_compatibility

Re-check the test and repaint the tree.

show_units_panel

Open the units pane on one data object, leaving the plot up.

rename_selected

Start inline editing of the selected name.

rename_object

Rename an object from code, numbering a taken name the way

edit_entities

Edit a geometry's nodes, coordinate systems, tracelines,

set_add_mode

Toggle creating things by clicking in the 3D view.

set_rotate_mode

Show the rings, and take over the mouse while one is dragged.

reset_rotation

Put the frame back to no rotation, keeping where it sits.

hover_at

Light up whatever is under this pixel; returns the entity.

select_entity

Select the table row for an entity picked in the 3D view.

about

The version and what it is: an alpha, to be checked, with

check_for_updates

File → Check for Updates: ask visualdynamics.org for the

dragEnterEvent

Anywhere on the window will do.

event

Keyboard focus is parked while the window is inactive.

import_paths

Import several files, reporting any failures once at the end.

save_test

Save the whole project — every object, under its name — one file.

copy_selected

Cmd/Ctrl+C in the tree: the selected objects onto the

paste_objects

Cmd/Ctrl+V in the tree: whatever the clipboard holds that

save_selected

Write the selected object, in whichever form is asked for.

render_current

Render everything selected: geometries overlay, curves share axes.

add_matches

Commit the selected MAC squares to the matched modes for

compute_spectra

Averaged spectra from the selected time history, one record

project_onto_basis

The other set sampled at the Basis set's DOFs, added as a

transform_selection

The selected record through the selected shape set — modal

generate_typed_report

Generate the report the project's declared type calls for.

extract_sine_levels

Each specification tone's level, read out of the recording

compute_psds

Averaged auto-power spectral densities from the selected

compute_octave

A spectrum on proportional bands, added beside it — the

compute_frfs

FRFs from the selected time history, added beside it.

compute_multiple_coherence

Multiple coherence from the selected time history.

compute_srs

Shock response spectra from the selected time history — one

filter_data

The selected time history through its filter, every

truncate_data

The selected time history cut to its span, every channel,

generate_rigid_body_modes

The six rigid-body mode shapes of the selected geometry,

integrate_history

One integration of the selected time history — acceleration

differentiate_history

One differentiation of the selected time history —

compute_cpsds

The full cross-spectral matrix from the selected time

generate_report

A new Report object from a starter template, opened to edit.

export_report_template

Write the selected report on its own, as a template another

export_report

Write the selected report as one self-contained HTML file.

merge_selected

Replace the selected objects with their combination.

start_modal_fit

Fit real normal modes to the selected FRFs, one mode at a time.

refine_all_modes

Re-fit every confirmed mode's residues together, poles held —

find_next_mode

Put the cursor on the next mode worth fitting.

fit_pending_mode

Fit at the cursor without confirming, and restate everything.

Attributes:

Name Type Description
objects Project

The project's objects: a mapping of name to object.

speed float

How fast the animation runs, as a multiple of the normal rate.

element_type tuple[int, int]

(type code, node count) for the element type now selected.

Source code in src/visualdynamics/gui/main_window.py
 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
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
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
1084
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
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
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
1214
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
1281
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
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
def __init__(self, offscreen_3d: bool = False) -> None:
    """offscreen_3d replaces the embedded VTK view with an off-screen
    plotter — VTK's Qt widget cannot run under Qt's offscreen platform,
    so headless tests use this to exercise the full render path."""
    super().__init__()
    from .project_tree import start_trace

    start_trace()               # the drop log begins with the window
    #: where keyboard focus waits out the window's inactive spells —
    #: see `event` for the macOS drop bug this dodges. Zero-size on
    #: purpose: the bug poisons the focused widget's rect, and this
    #: rect cannot be hit.
    self._focus_park: QWidget = QWidget(self)
    self._focus_park.setFixedSize(0, 0)
    self._focus_park.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
    self._parked_focus: QWidget | None = None
    # The QWindow sees platform drag events before any widget
    # dispatch, so its line splits the world in two: present here
    # and absent from a widget is Qt losing it; absent here is the
    # platform never delivering it. Installed on our own QWindow
    # alone — an application-wide filter touched Chromium's
    # machinery and segfaulted.
    self._drag_watch = _WindowDragWatch(self)
    self.setWindowTitle('Visual Dynamics')
    self.resize(1280, 800)
    self.setAcceptDrops(True)
    self.unit_system: UnitSystem = DEFAULT_SYSTEM
    # the window shows a Project and owns nothing the Project owns:
    # objects, links, type and active geometry are its fields,
    # reached through the properties below, so clicking and
    # scripting act on one structure rather than two that agree
    self.project: Project = Project()
    #: name -> RecordGrid, while that object's row is expanded
    self.record_grids: dict[str, Any] = {}
    self._rows_wired = False  # is rows_deleted connected right now
    #: name -> the file it was read from
    self.object_sources: dict[str, str] = {}
    #: name -> what that file called it
    self.object_keys: dict[str, str] = {}
    #: the last compatibility check over the project
    self.report: Any = None
    self._status_text = ''

    from . import check_qt_binding

    check_qt_binding()   # a mismatch is unreadable once it reaches a layout
    import pyqtgraph as pg

    # follow the OS light/dark setting for the parts we draw ourselves
    self.theme_name: str = system_scheme()
    colors = resolve_theme(self.theme_name)
    pg.setConfigOption('background', colors['plot_background'])
    pg.setConfigOption('foreground', colors['plot_foreground'])

    # panes stack top to bottom: geometry above its data
    self.views: QSplitter = QSplitter(Qt.Orientation.Vertical)
    #: the live deflection, while data is shown on a geometry
    self.animator: Any = None
    self._playing = False
    self._rendering = False       # drop frames rather than queue them
    self._shape_mode = False      # phase sweep, versus stepping samples
    #: (geometry name, entity kind) while an edit table is open
    self.editing: tuple[str, str] | None = None
    #: clicking in the view creates things
    self.add_mode: bool = False
    self._picked_nodes = []       # nodes gathered for a traceline/element
    self._shape_source = None     # (geometry, shape set) while a mode plays
    #: (geometry name, the proposed set) while the rigid-body
    #: reading previews on the scene
    self._rigid_preview = None
    self._picker = None           # entity under the cursor, while editing
    self._projector = None
    self._hovered = None
    self._hover_mesh = None
    self._picked_mesh = None
    self._framed = ()          # what the camera was last framed for
    self._rotating = None      # the ring gesture in progress
    self._rotate_observers = []
    self._last_axis = 2        # the ring a typed angle turns about
    self._pick_observers = []
    self._cursor = None
    self._cursor_dimension = None
    self._cursor_abscissa = None
    self._frame_index = 0
    # the same position, unrounded: see `_advance`
    self._frame_position = 0.0
    self._phase_position = 0.0
    # the 3-D view is a pane of its own: it owns the render window, the
    # annotations drawn around whatever is in it, and the bar they hang
    # from. What goes *into* the scene needs the project, so that stays
    # here and is added to the same bar.
    #: kept for the 3-D surfaces built later than the panes — the
    #: MAC bars' plotter is made on first use, long after __init__
    self._offscreen_3d: bool = offscreen_3d
    self.scene: ScenePane = ScenePane(self.theme_name,
                                      offscreen=offscreen_3d)
    self._build_view_toolbar(self.scene.toolbar)
    # the 2-D data view is a pane of its own: it owns the plot, the bar
    # that says how to read it, and that choice — and tells us when the
    # choice moves rather than acting on it
    self.data_pane: DataPane = DataPane(self.theme_name,
                                        offscreen=offscreen_3d)
    #: which object the *pager* is counting pages for — shared by
    #: both readings, since it is one control on one bar. Separate
    #: from `_waterfall_object` below, which answers a different
    #: question: where the 3-D camera belongs.
    self._paged_object: str | None = None
    #: which object the waterfall camera was placed for. The camera
    #: is placed when this changes and at no other time — a reread
    #: (units switch, record pick, component change) redraws the
    #: scene under the view the user set
    self._waterfall_object: str | None = None
    #: drags the stage's averaging span and shock windows; built
    #: with the stage plotter on first use
    self._stage_dragger = None
    #: coalesces report rebuilds during a drag — see
    #: `_report_content_changed`
    self._report_settle: QTimer = QTimer(self)
    self._report_settle.setSingleShot(True)
    self._report_settle.timeout.connect(self._report_content_changed)
    #: how to restate the stage's filter twin when the panel
    #: moves — set by `_stage_filter_preview`, which is the
    #: only thing that knows this render's extents
    self._stage_filter_redraw: Any = None
    #: {name: why} for derived objects whose source's settings have
    #: moved — read by the badges, refreshed at every settings edit
    self._stale: dict[str, str] = {}
    self.data_pane.reread.connect(self.render_current)
    self.data_pane.log_frequency_action.triggered.connect(
        self._frequency_axis_toggled)
    self.data_pane.drive_points_toggled.connect(self._toggle_drive_points)
    self.data_pane.residual_toggled.connect(self._toggle_fit_residual)
    self.data_pane.pair_mode_chosen.connect(self._set_pair_mode)
    self.data_pane.averaging_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.averaging_panel.changed.connect(self._averaging_edited)
    self.data_pane.shocks_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.filter_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.filter_panel.changed.connect(self._filtering_edited)
    self.data_pane.truncate_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.truncate_panel.changed.connect(
        self._truncation_edited)
    self.data_pane.octave_toggled.connect(
        lambda _wanted: self.render_current())
    self.data_pane.octave_panel.changed.connect(
        lambda _per: self.render_current())
    self.data_pane.octave_panel.apply_asked.connect(
        self.compute_octave)
    # the acts, asked for where their settings are set (Brandon,
    # 2026-08-28): each button is a second entrance to the same
    # verb the bar's act offers, so provenance, badges and the
    # scripting API are untouched
    self.data_pane.filter_panel.apply_asked.connect(self.filter_data)
    self.data_pane.truncate_panel.apply_asked.connect(
        self.truncate_data)
    # the specification draft: every edit re-previews, the button
    # makes the object. A specification opened to edit is drawn on
    # the plot, so its toggle sits on the plot's own bar
    self.data_pane.author_panel.changed.connect(self._author_edited)
    self.data_pane.author_panel.form_asked.connect(self._author_form_asked)
    self.data_pane.author_panel.constraint_asked.connect(
        self._author_constraint_asked)
    self.author_data_action: QAction = QAction(control_icon('edit'),
                                               'Edit', self)
    self.author_data_action.setCheckable(True)
    self.author_data_action.setToolTip(
        'Open this specification as a sheet — breakpoints, cross '
        'terms, bands — and replace it')
    self.author_data_action.triggered.connect(self._author_toggled)
    self.data_pane.toolbar.addAction(self.author_data_action)
    self.author_data_action.setVisible(False)
    # the scene's own reading: the rigid-body preview and its act
    self.scene.rigid_toggled.connect(
        lambda _wanted: self.render_current())
    self.scene.rigid_panel.changed.connect(self._rigid_edited)
    self.scene.rigid_panel.apply_asked.connect(
        self.generate_rigid_body_modes)
    self.data_pane.shock_panel.srs_asked.connect(self.compute_srs)
    self.data_pane.averaging_panel.psds_asked.connect(
        self.compute_psds)
    self.data_pane.averaging_panel.cpsds_asked.connect(
        self.compute_cpsds)
    self.data_pane.averaging_panel.spectra_asked.connect(
        self.compute_spectra)
    self.data_pane.averaging_panel.frfs_asked.connect(
        self.compute_frfs)
    self.data_pane.averaging_panel.coherence_asked.connect(
        self.compute_multiple_coherence)
    self.data_pane.shock_panel.changed.connect(self._shocks_edited)
    self.data_pane.shock_panel.length_mode_changed.connect(
        self._shock_length_mode)
    self.data_pane.shock_panel.detect_asked.connect(self._detect_shocks)
    self.data_pane.shock_panel.add_asked.connect(self._add_shock)
    self.data_pane.spectra_view_chosen.connect(
        lambda _which: self.render_current())
    self.data_pane.comparison_chosen.connect(
        lambda _which: self.render_current())
    self.data_pane.scaling_edited.connect(self._comparison_scale_edited)
    # the channel box and the channel table ask the same question,
    # so whichever was touched last is the answer. Connected before
    # the redraw below, because it has to have taken effect by the
    # time the drawing reads it
    self.data_pane.pair_chosen.connect(
        lambda: setattr(self, '_replication_pairs', []))
    self.data_pane.pair_chosen.connect(
        lambda: setattr(self, '_compliance_channels', []))
    self.data_pane.pair_chosen.connect(self.render_current)
    self.data_pane.replication_chosen.connect(
        lambda _which: self.render_current())
    self.data_pane.event_chosen.connect(self._event_chosen)
    self.data_pane.srs_chosen.connect(
        lambda _which: self.render_current())
    # ...and from anywhere, for when the tree has the keyboard and
    # plain arrows are its own
    for keys, step in (('Alt+Up', -1), ('Alt+Down', 1)):
        shortcut = QShortcut(QKeySequence(keys), self)
        shortcut.activated.connect(
            lambda step=step: self.data_pane.step_pair(step))
    #: the frames drawn on the time history, one overlay per plot the
    #: history was split across — force and acceleration get a row each
    self.averaging_overlays: list[Any] = []
    self.filter_overlays: list[Any] = []
    self.truncation_overlays: list[Any] = []
    self.octave_previews: list[Any] = []
    #: and the shock windows, the same way
    self.shock_overlays: list[Any] = []
    #: which comparison the plot is drawing, so the compliance table
    #: can mark its row; and the guard that stops the two of them
    #: from chasing each other
    self._plotted_pair = None
    self._syncing_compliance = False
    #: the bar chart currently up, when the comparison is being read
    #: as one rather than as spectra
    self.bar_chart: Any = None
    self.table: CopyPasteTableView = CopyPasteTableView()
    self.table.edits_applied.connect(self._report_edits)
    # the table with, in fit mode, its Confirm bar underneath
    self.table_pane: QWidget = QWidget()
    table_layout = QVBoxLayout(self.table_pane)
    table_layout.setContentsMargins(0, 0, 0, 0)
    table_layout.setSpacing(0)
    # the auto-MAC sits to the right of a mode table — how distinct
    # the modes are belongs beside the list of them. Square, because
    # the matrix is; the frame keeps it that way whatever the pane does
    self.mac_view: Any = pg.GraphicsLayoutWidget()
    self.mac_frame: _RatioFrame = _RatioFrame(self.mac_view)
    self.mac_frame.hide()
    self.mac_view.scene().sigMouseClicked.connect(self._mac_clicked)
    # comparing shape sets is often about the MAC alone: a bar over
    # the pane toggles the mode table on the left away
    self.table_bar: QToolBar = QToolBar()
    self.table_bar.setIconSize(QSize(20, 20))
    self.mode_table_action: QAction = QAction(control_icon('table'),
                                     'Mode Table', self)
    self.mode_table_action.setCheckable(True)
    self.mode_table_action.setChecked(True)
    self.mode_table_action.setToolTip(
        'Show the table beside the MAC — the mode list for one '
        'set, the matched-modes table while comparing two')
    self.mode_table_action.triggered.connect(
        lambda checked: self.table.setVisible(checked))
    # the MAC's own 3-D reading: the matrix as bars, height and
    # colour both the value, and the default here too (Brandon's
    # call, made once the bars could be picked like the grid)
    self.mac_bars_action: QAction = QAction(control_icon('waterfall'),
                                   '3D', self)
    self.mac_bars_action.setCheckable(True)
    self.mac_bars_action.setChecked(True)
    self.mac_bars_action.setToolTip(
        'The MAC as 3-D bars, height and colour the value — '
        'click picks a pair, Shift adds it, exactly as on the grid')
    self.mac_bars_action.triggered.connect(self._rerender_mac)
    # the 2D/3D toggle leads this bar, as it leads the data pane's
    # (Brandon, 2026-08-30): the same control sits in the same
    # place whichever view is up
    self.table_bar.addAction(self.mac_bars_action)
    self.table_bar.addAction(self.mode_table_action)
    self.mac_bars_action.setVisible(False)
    # writing a specification at the set's modal coordinates: the
    # reading of a lone shape set that makes an object (PLAN.md,
    # "The virtual point arc"). Its pane sits beside the plot,
    # which previews the autospectra as they are typed
    self.author_action: QAction = QAction(control_icon('edit'),
                                          'Specification', self)
    self.author_action.setCheckable(True)
    self.author_action.setToolTip(
        'Write a specification at this set\'s modal coordinates — '
        'breakpoints, every cross term, the bands — and make it')
    self.author_action.triggered.connect(self._author_toggled)
    self.table_bar.addAction(self.author_action)
    self.author_action.setVisible(False)
    self._author_wanted: bool = False
    #: the draft per object it was opened on, kept while the
    #: window lives
    self._drafts: dict[str, Any] = {}
    #: which objects each cached draft was opened on — so a write
    #: through one door (the whole specification) drops the drafts
    #: cached under its other doors (one picked channel), which
    #: otherwise kept showing the object as it was (Brandon,
    #: 2026-09-06: the sub-items had not updated back to ±3 dB)
    self._draft_names: dict[str, tuple[str, ...]] = {}
    #: what the reading is open on: ('shapes' | 'spec' | 'table', name)
    self._author_door: tuple[str, str] | None = None
    #: the specification a shape-set or channel-table door made, by
    #: door key — a render between the making and the deferred
    #: switch of selection must not make a second one
    self._author_made: dict[str, str] = {}
    #: the 3-D MAC surface, built on first use, and which comparison
    #: its camera was placed for — placed on a change, kept on a
    #: reread, the same standing rule as every scene
    self._mac_bars_page: QWidget | None = None
    self._mac_bars_plotter_obj: Any = None
    self._mac_bars_shown: Any = None
    #: (rows, columns) of the bars last drawn — what a picked mesh
    #: cell is decoded against
    self._mac_bars_grid: tuple[int, int] | None = None
    # comparing two sets that live on a geometry: the picked pair
    # can animate overlaid, phase-aligned — the toggle remembers
    self.overlay_action: QAction = QAction(control_icon('overlay'),
                                  'Overlay Animation', self)
    self.overlay_action.setCheckable(True)
    self.overlay_action.setChecked(True)
    self.overlay_action.setToolTip(
        'Animate the picked pair of modes over each other, the '
        'second phase-aligned to the first')
    self.overlay_action.triggered.connect(
        lambda _checked: self.render_current())
    self.table_bar.addAction(self.overlay_action)
    self.overlay_action.setVisible(False)
    # committing picked MAC squares builds the matched-modes
    # object, the way fitting builds the shape set
    self.add_matches_action: QAction = QAction(control_icon('add'),
                                      'Add Matches', self)
    self.add_matches_action.setToolTip(
        'Add the selected MAC squares to the matched modes for '
        'this pair of sets — created on first use')
    self.add_matches_action.triggered.connect(self.add_matches)
    self.table_bar.addAction(self.add_matches_action)
    self.add_matches_action.setVisible(False)
    self.table_bar.hide()
    table_layout.addWidget(self.table_bar)
    # a splitter, not a row: how much of the pane the mode list wants
    # against how big the MAC should be is a judgement about the data
    # in front of you, so it is a divider to drag rather than a ratio
    # we picked
    self.tables_row: QSplitter = QSplitter(Qt.Orientation.Horizontal)
    self.tables_row.setChildrenCollapsible(False)
    self.tables_row.addWidget(self.table)
    self.tables_row.addWidget(self.mac_frame)
    self.tables_row.setSizes([500, 500])
    # the report editor borrows this pane: the rendered report
    # itself, editable — created on first use, Chromium is heavy
    self.report_editor: Any = None
    self._table_layout = table_layout
    # the splitter takes the height going spare; without the
    # stretch the button bar below it claimed an even share and
    # stood a quarter of the window tall
    table_layout.addWidget(self.tables_row, 1)
    self.fit_bar: QWidget = QWidget()
    fit_layout = QHBoxLayout(self.fit_bar)
    fit_layout.setContentsMargins(6, 4, 6, 4)
    fit_layout.addStretch(1)
    # Left of Find Mode, and first into the layout, because the
    # bar is right-aligned by the stretch above: the group grows
    # and shrinks at its *left* edge, so Refine All appearing after
    # the second mode does not shift Find Mode and Confirm Mode out
    # from under a cursor already on them. Added last, it pushed
    # both of them left the moment it showed up.
    #
    # Sequential fitting never goes back: the first of a close pair
    # was fit on data still containing the second. This goes back —
    # poles held, every mode's residues re-fit together. Offered
    # only once there are two modes to influence each other.
    self.refine_modes_button: QPushButton = QPushButton('Refine All')
    self.refine_modes_button.setToolTip(
        'Re-fit every confirmed mode\'s shape together, holding the '
        'frequencies and dampings — close modes stop borrowing from '
        'each other')
    self.refine_modes_button.clicked.connect(self.refine_all_modes)
    fit_layout.addWidget(self.refine_modes_button)
    self.find_mode_button: QPushButton = QPushButton('Find Mode')
    self.find_mode_button.setToolTip(
        'Move the cursor to the largest CMIF peak left in the '
        'residual, within the frequencies on screen — the next '
        'mode to fit. Zoom to narrow the search.')
    self.find_mode_button.clicked.connect(self.find_next_mode)
    fit_layout.addWidget(self.find_mode_button)
    self.confirm_mode_button: QPushButton = QPushButton('Confirm Mode')
    self.confirm_mode_button.setToolTip(
        'Fit a real normal mode at the cursor and move it to the next '
        'largest residual peak')
    self.confirm_mode_button.clicked.connect(self.confirm_fit_mode)
    fit_layout.addWidget(self.confirm_mode_button)
    table_layout.addWidget(self.fit_bar)
    self.fit_bar.hide()
    for pane in (self.scene, self.data_pane, self.table_pane):
        self.views.addWidget(pane)
    self.data_pane.hide()
    self.table_pane.hide()
    # declaring units sits beside the views rather than over them: the
    # plot is how you tell what a channel is, so it has to stay visible
    #: (name, object) while the imported-units pane is up
    self.units_target: tuple[str, Any] | None = None
    #: (channel, playing) pairs the replication grid is pointing
    #: the plot at, kept here rather than in the table because the
    #: table is rebuilt on every drawing and the choice is not
    self._replication_pairs = []
    #: DOF pairs the compliance table is pointing the plot at — the
    #: same arrangement the transient grid has, for a comparison
    #: with no repeats to spread across
    self._compliance_channels = []
    #: set while a redraw is already queued, so a drag across the
    #: grid draws once rather than once per cell it crosses
    self._replication_pending = False
    #: the grid on screen and the model showing it, so a drawing
    #: that changes nothing about them leaves the user's selection
    #: alone rather than rebuilding it from this end's idea of it
    self._replication_holder = None
    self._replication_model = None
    self._compliance_holder = None
    self._compliance_model = None
    self._replication_units = '%'
    #: the ModalFitSession while a modal fit is open
    self.fit: Any = None
    self.fit_name: str | None = None
    self.fit_object_name: str | None = None
    self._fit_cursor = None
    self._fit_parabola = None
    self._fit_damping_label = None
    self._fit_coherence_name = None
    #: what the plot's top and bottom edges mean as damping, kept
    #: across fits — an article's plausible range rarely changes
    #: between two of its own FRF sets
    self._fit_damping_range: list[float] = [self.FIT_DAMPING_TOP,
                                            self.FIT_DAMPING_BOTTOM]
    self._fit_range_edits: list | None = None
    #: the dashed synthesis curves currently on the CMIF,
    #: for updating in place while the cursor is dragged
    self._fit_synthesis = []
    #: fires when the cursor has been still a moment
    self._fit_settle = QTimer(self)
    self._fit_settle.setSingleShot(True)
    self._fit_settle.timeout.connect(self._fit_settled)
    #: set while a drag tick is running, so the next is
    #: dropped rather than queued
    self._fit_dragging = False
    #: what the dear tier — the fit and the dashed synthesis —
    #: cost the last time it ran, in seconds. The gate on a drag
    #: tick reads this *before* paying: the old scheme ran the
    #: dear tier first and checked the budget after, which on the
    #: hard drone survey meant every drag opened with the full
    #: bill — seconds of freeze — before the guard could notice,
    #: and paid it again after every pause, because the settle
    #: re-armed it. Remembered cost, primed by the settle that
    #: `start_modal_fit` schedules, means a set too big to follow
    #: live never stalls a single tick.
    self._fit_dear_cost = 0.0
    #: whether the dashed synthesis has fallen behind the cursor —
    #: a tick that skipped the dear tier sets it, and the settle
    #: catches up
    self._fit_synthesis_stale = True
    self._fit_model = None
    self._fit_plot = None
    self._compare = None           # {'pair', 'cell'} while comparing
    #: (spec name, measured name) the Scaling field is editing
    self._scaling_pair = None
    #: an import is running; a second one queued behind it waits
    self._importing = False
    #: what MAC the mac_view currently shows, so redrawing the same
    #: comparison keeps the zoom (picking cells re-renders it)
    self._mac_shown = None
    #: re-entrancy guard: showing a pair rebuilds the table that
    #: asked for it, and its selection would ask again
    self._matched_row_moving = False
    self._compare_active = False
    #: how an FRF and a shape set together read: 'fit' | 'overlay'
    self.pair_mode: str = 'fit'
    self._pair_selected = False
    self._pair_with_picks = False
    self._units_restate = QTimer(self)
    self._units_restate.setSingleShot(True)
    self._units_restate.timeout.connect(self._restate_after_units)
    self.main_split: QSplitter = QSplitter(Qt.Orientation.Horizontal)
    self.main_split.addWidget(self.views)
    self.main_split.addWidget(self._build_units_panel())
    self.main_split.setStretchFactor(0, 3)
    self.main_split.setStretchFactor(1, 1)
    self.units_panel.hide()
    # the console rides the bottom edge as a tab (Brandon,
    # 2026-08-30): collapsed by default, one click to expand the
    # session's journal, one to put it away — no menu to find
    from .console import ConsolePanel

    centre = QWidget()
    column = QVBoxLayout(centre)
    column.setContentsMargins(0, 0, 0, 0)
    column.setSpacing(0)
    column.addWidget(self.main_split, 1)
    self.console: ConsolePanel = ConsolePanel()
    column.addWidget(self.console)
    # the tab is not a layout row: floated over the views' bottom
    # edge, so collapsed the console claims no strip at all
    self.console.float_over(centre)
    # what the tab covers, the 2-D plots leave empty: legend row
    # and bottom axis end above it, whichever pane it lands on
    self.data_pane.reserve_bottom(
        self.console.tab.sizeHint().height() + 4)
    self.setCentralWidget(centre)

    # Dragging the divider between the tree and the views has to
    # track the cursor. AnimatedDocks — on by default — eases every
    # dock geometry change, so mid-drag the divider trails the mouse
    # rather than following it. And the separator is 4 px by default,
    # a target you aim at rather than a thing you grab.
    self.setDockOptions(QMainWindow.DockOption.AllowNestedDocks
                        | QMainWindow.DockOption.AllowTabbedDocks)
    self.setStyleSheet(self.styleSheet()
                       + '\nQMainWindow::separator { width: 7px; '
                         'height: 7px; }\n')

    self.tree: ProjectTree = ProjectTree()
    # a drop on the tree is the same import as a drop anywhere else,
    # queued the same way — see _import_after_drop for why it is
    # queued at all
    self.tree.files_dropped.connect(self._import_after_drop)
    self.tree.objects_moved.connect(self._move_objects)
    self.tree.landing_for = self._landing_of
    self.tree.write_for_drag = self._write_for_drag
    # column 1 carries the refresh badge of a stale object and the
    # edit pencil of a sub-row; the acts are on the bar
    self.tree.setColumnCount(2)
    self.tree.setHeaderLabels(['Project', ''])
    self.tree.header().setStretchLastSection(False)
    self.tree.header().setSectionResizeMode(
        0, QHeaderView.ResizeMode.Stretch)
    self.tree.setColumnWidth(1, 26)
    self.tree.itemClicked.connect(self._tree_item_clicked)
    self.tree.setIconSize(QSize(20, 20))
    self.tree.currentItemChanged.connect(self._selection_changed)
    self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
    self.tree.customContextMenuRequested.connect(self._show_tree_menu)
    self.tree.setSelectionMode(
        QAbstractItemView.SelectionMode.ExtendedSelection)
    self.tree.itemSelectionChanged.connect(self._selection_changed)
    self.tree.setEditTriggers(
        QTreeWidget.EditTrigger.DoubleClicked
        | QTreeWidget.EditTrigger.EditKeyPressed)
    self.test_item: QTreeWidgetItem = QTreeWidgetItem(['Project'])
    self.test_item.setIcon(0, type_icon('Test'))
    self.test_item.setData(0, ROLE_ORIGINAL_NAME, 'Project')
    self.test_item.setData(0, ROLE_REFERENCE, ('test', None, None))
    # draggable *out* of the window, where it saves the whole
    # project; not ROLE_DRAGGABLE, which means movable between
    # link groups and the project belongs to none
    self.test_item.setData(0, ROLE_WHOLE_PROJECT, True)
    self.test_item.setFlags(self.test_item.flags() | Qt.ItemFlag.ItemIsEditable)
    self.tree.addTopLevelItem(self.test_item)
    self.test_item.setExpanded(True)
    self.tree.setCurrentItem(self.test_item)

    # linking lives on a small bar above the tree, beside the same
    # actions on the context menu
    tree_bar = QToolBar('Links')
    tree_bar.setMovable(False)
    tree_bar.setIconSize(QSize(16, 16))
    self.link_action: QAction = QAction(control_icon('link'), '&Link', self)
    self.link_action.setToolTip(
        'Link the selected objects so they are known to belong '
        'together — shapes to their geometry')
    self.link_action.triggered.connect(self.link_selected)
    tree_bar.addAction(self.link_action)
    self.link_action.setVisible(False)
    self.unlink_action: QAction = QAction(control_icon('unlink'), '&Unlink',
                                 self)
    self.unlink_action.setToolTip(
        'Take the selected objects out of their link')
    self.unlink_action.triggered.connect(self.unlink_selected)
    tree_bar.addAction(self.unlink_action)
    self.unlink_action.setVisible(False)
    # Everything in the dock takes a file drag the way the tree
    # does. The dock's chrome — the title bar, the toolbar, the
    # holder around the tree — accepted nothing, and a drag refused
    # there is left to Qt walking up to the window, which through a
    # dock's widget stack is exactly the walk that misses (the
    # tree's module docstring records it). On a fresh window the
    # tree is a strip about 90 px wide, so most of the left side
    # *was* chrome: importing by drop worked on the right side of
    # the window and only sometimes on the left, which reads as a
    # broken feature aimed at the one place that names the project.
    tree_holder = _TakesFileDrops()
    tree_layout = QVBoxLayout(tree_holder)
    tree_layout.setContentsMargins(0, 0, 0, 0)
    tree_layout.setSpacing(0)
    tree_layout.addWidget(tree_bar)
    tree_layout.addWidget(self.tree)
    tree_holder.files_dropped.connect(self._import_after_drop)
    # The tree is not a view of the project, it is how you say what
    # the views are of: every pane answers to a selection made here,
    # and there is only ever the one window, so floating it out would
    # leave an empty one behind. It stays — movable between edges,
    # never floating, never closed.
    self.project_dock: QDockWidget = _FileDropDock('Project')
    self.project_dock.setFeatures(
        QDockWidget.DockWidgetFeature.DockWidgetMovable)
    self.project_dock.files_dropped.connect(self._import_after_drop)
    self.project_dock.setWidget(tree_holder)
    self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea,
                       self.project_dock)
    # the dock follows its content: expanding a 20-average grid widens
    # it instead of showing one column of the grid through a slot

    self.active_geometry_action: QAction = QAction('Set as &Active Geometry', self)
    # never `connect(self.set_active_geometry)`: `triggered` passes
    # its checked bool, which landed in `name` as False — not None —
    # so the verb skipped the current-item lookup, looked up the
    # object called False, and refused. The menu entry did nothing,
    # ever, and said "Select a geometry" to a selected geometry.
    self.active_geometry_action.triggered.connect(
        lambda _checked=False: self.set_active_geometry())
    self.edit_action: QAction = QAction('&Edit', self)
    self.edit_action.triggered.connect(self.edit_entities)
    # Save As lives on the object's context menu and acts on that
    # one object; the File menu saves only the whole project. It
    # asks for the format in the dialog's own file-type list, which
    # is where a save dialog has always asked, so there is no second
    # verb for the foreign formats.
    self.save_action: QAction = QAction('&Save As...', self)
    self.save_action.setToolTip(
        'Write this object — as a .vdyn, or in a format another '
        'tool can read')
    self.save_action.triggered.connect(self.save_selected)
    self.export_report_action: QAction = QAction('Export &Report...', self)
    self.export_report_action.setToolTip(
        'Write this report as one self-contained HTML file — opens '
        'in any browser, nothing to install')
    self.export_report_action.triggered.connect(self.export_report)
    self.save_test_action: QAction = QAction('Save &Project As...', self)
    self.save_test_action.setToolTip(
        'Save the whole project — every object, under its name — as '
        'one .vdyn file')
    self.save_test_action.triggered.connect(self.save_test)
    self.delete_action: QAction = QAction('&Delete Selected', self)
    # fires while focus is in the tree, the table or the 3D view — picking
    # in the 3D view selects table rows, so delete has to reach it too.
    # WithChildren: the panes hold focus in a child (a viewport, or VTK's
    # render widget), never themselves.
    self.delete_action.setShortcuts([QKeySequence.StandardKey.Delete,
                                     QKeySequence(Qt.Key.Key_Backspace)])
    self.delete_action.setShortcutContext(
        Qt.ShortcutContext.WidgetWithChildrenShortcut)
    self.delete_action.triggered.connect(self.delete_selected)
    self.tree.addAction(self.delete_action)
    self.scene.addAction(self.delete_action)
    # deliberately not on the table: there, Delete clears the selected
    # cells like it does in every table. It is added back only while
    # editing geometry, where the rows *are* the entities and removing
    # them is what Delete has always meant. See _set_table_model.
    # Escape and Return belong to a geometry editing session: one
    # abandons it, the other makes a traceline or an element from
    # the nodes picked so far. They were **window** shortcuts, and a
    # window shortcut is answered before the focused widget sees the
    # key — so Return typed into any editor anywhere in the window
    # was swallowed by a verb that was usually a no-op. Renaming a
    # row could not be finished with the Return key; it had to be
    # clicked away from, which reads as an editor that will not
    # close.
    #
    # Scoped to the two places a picking session actually lives, so
    # an editor keeps its own keys. The tree is not one of them.
    for action, keys, verb in (
            ('escape_action', [QKeySequence(Qt.Key.Key_Escape)],
             self.stop_editing),
            ('commit_action', [QKeySequence(Qt.Key.Key_Return),
                               QKeySequence(Qt.Key.Key_Enter)],
             self._commit_picked_nodes)):
        made = QAction('Stop editing' if verb is self.stop_editing
                       else 'Create from picked nodes', self)
        made.setShortcuts(keys)
        made.setShortcutContext(
            Qt.ShortcutContext.WidgetWithChildrenShortcut)
        made.triggered.connect(verb)
        # the render window holds focus in a child of its own, which
        # is why it is WithChildren rather than WidgetShortcut
        self.scene.addAction(made)
        self.table.addAction(made)
        setattr(self, action, made)
    # Copy and Paste on the tree alone: the tables keep their own
    # spreadsheet clipboard, and a copy from the tree is the
    # selection as objects (paste back: duplicates) and as .vdyn
    # files (paste into a folder: an export) — Brandon, 2026-09-03
    self.copy_action: QAction = QAction('&Copy', self)
    self.copy_action.setShortcut(QKeySequence.StandardKey.Copy)
    self.copy_action.setShortcutContext(
        Qt.ShortcutContext.WidgetShortcut)
    self.copy_action.triggered.connect(self.copy_selected)
    self.tree.addAction(self.copy_action)
    self.paste_action: QAction = QAction('&Paste', self)
    self.paste_action.setShortcut(QKeySequence.StandardKey.Paste)
    self.paste_action.setShortcutContext(
        Qt.ShortcutContext.WidgetShortcut)
    self.paste_action.triggered.connect(self.paste_objects)
    self.tree.addAction(self.paste_action)
    self.tree.objects_pasted.connect(self._paste_named_objects)
    self.rename_action: QAction = QAction('&Rename', self)
    self.rename_action.setShortcut(QKeySequence(Qt.Key.Key_F2))
    self.rename_action.setShortcutContext(
        Qt.ShortcutContext.WidgetShortcut)
    self.rename_action.triggered.connect(self.rename_selected)
    self.tree.addAction(self.rename_action)
    self.tree.itemChanged.connect(self._item_renamed)
    self.tree.itemExpanded.connect(self._populate_entities)
    self.tree.setMouseTracking(True)
    self.tree.itemEntered.connect(self._hover_item)

    self.unit_combo: QComboBox = QComboBox()
    self.unit_combo.addItems(list(SYSTEMS))
    self.unit_combo.setCurrentText(DEFAULT_SYSTEM.name)
    self.unit_combo.currentTextChanged.connect(self._units_changed)
    # right-justified in the status bar, keeping the chrome minimal
    self.statusBar().addPermanentWidget(QLabel('Display units:'))
    self.statusBar().addPermanentWidget(self.unit_combo)

    # deliberately spare: import in, project out. Per-object save,
    # export and delete live on the object's own context menu.
    file_menu = self.menuBar().addMenu('&File')
    file_menu.addAction('&Import...', self.import_files)
    file_menu.addAction(self.save_test_action)
    report_menu = file_menu.addMenu('Generate &Report')
    report_menu.addAction('&Modal Test',
                          lambda: self.generate_report('modal'))
    report_menu.addAction('&Random Vibration',
                          lambda: self.generate_report('random'))
    report_menu.addAction('&Transient',
                          lambda: self.generate_report('transient'))
    report_menu.addAction('&Shock',
                          lambda: self.generate_report('shock'))
    report_menu.addAction('&Empty',
                          lambda: self.generate_report('empty'))
    file_menu.addSeparator()
    file_menu.addAction('Check for &Updates...', self.check_for_updates)
    # the About role: macOS moves it into the application menu,
    # where a Mac user looks for a version; Windows and Linux keep
    # it here (Brandon, 2026-09-12: no easy way to see the version)
    about = file_menu.addAction('&About Visual Dynamics...', self.about)
    about.setMenuRole(QAction.MenuRole.AboutRole)
    file_menu.addSeparator()
    file_menu.addAction('&Quit', self.close)

    self._show_status('Import a file to get started')
    # How far along an import is, beside the 'Importing …' text —
    # *beside* it, on the left, which takes more than a layout
    # choice: a temporary status message obscures the status bar's
    # normal (left) widgets, so a permanent widget on the right was
    # the only place a bar survived showMessage. Instead the import
    # brings its own strip — the text and the bar in one left-hand
    # widget — and while it runs, status lines go onto the strip's
    # label rather than through showMessage (see _show_status).
    # The bar itself is hidden except while there is something
    # honest to show: a project file reports per object and a
    # multi-file drop per file, but a single foreign file is one
    # unreported read, and a busy-bar over a blocked event loop
    # cannot animate — a frozen busy-bar reads as a hang, which is
    # worse than no bar.
    self._import_progress: QProgressBar = QProgressBar()
    self._import_progress.setMaximumWidth(200)
    self._import_progress.setTextVisible(False)
    self._import_label: QLabel = QLabel()
    strip = QWidget()
    strip_row = QHBoxLayout(strip)
    strip_row.setContentsMargins(6, 0, 0, 0)
    strip_row.setSpacing(8)
    # bar first, words after: the leftmost thing on the screen is
    # the one moving, and the label reads as its caption
    strip_row.addWidget(self._import_progress)
    strip_row.addWidget(self._import_label)
    self.statusBar().insertWidget(0, strip)
    self._import_strip: QWidget = strip
    strip.hide()
    self._import_progress.hide()

    hints = QApplication.instance().styleHints()
    if hasattr(hints, 'colorSchemeChanged'):
        # a bound method, never a lambda: the style hints live as
        # long as the application, and a lambda capturing self
        # would keep every closed window alive with its project —
        # the 15 MB-a-window leak that grew CI's workers to 2.7 GB
        # (2026-09-13). PySide drops a bound-method connection when
        # its receiver is destroyed.
        hints.colorSchemeChanged.connect(self._scheme_changed)
    # and once now: the panes coloured themselves at construction,
    # but the tree's palette only exists in apply_theme — without
    # this it wore the platform's grey until the OS switched theme
    self.apply_theme(self.theme_name)
Attributes
objects property
objects: Project

The project's objects: a mapping of name to object.

speed property
speed: float

How fast the animation runs, as a multiple of the normal rate.

element_type property
element_type: tuple[int, int]

(type code, node count) for the element type now selected.

Methods:
set_playing
set_playing(playing: bool) -> None

Start or stop the animation.

Source code in src/visualdynamics/gui/main_window.py
def set_playing(self, playing: bool) -> None:
    """Start or stop the animation."""
    playing = bool(playing) and self.animator is not None
    self._playing = playing
    if playing:
        self._timer.start()
    else:
        self._timer.stop()
    self.play_action.setEnabled(self.animator is not None and not playing)
    self.pause_action.setEnabled(playing)
apply_theme
apply_theme(name: str | None = None) -> None

Adopt a light/dark theme (default: whatever the OS is set to).

Source code in src/visualdynamics/gui/main_window.py
def apply_theme(self, name: str | None = None) -> None:
    """Adopt a light/dark theme (default: whatever the OS is set to)."""
    import pyqtgraph as pg

    self.theme_name = name or system_scheme()
    colors = resolve_theme(self.theme_name)
    _keep_selection_vivid(self.table)
    pg.setConfigOption('foreground', colors['plot_foreground'])
    self.data_pane.apply_theme(self.theme_name, colors)
    self.scene.apply_background()
    # the tree wears the scene's own ground — one theme for every
    # surface, black or white, never the platform's grey (Brandon,
    # 2026-08-23)
    palette = self.tree.palette()
    palette.setColor(QPalette.ColorRole.Base,
                     QColor(colors['scene_background']))
    palette.setColor(QPalette.ColorRole.Text,
                     QColor(colors['scene_text']))
    self.tree.setPalette(palette)
    _keep_selection_vivid(self.tree)
    if self.report is not None:
        self._paint_compatibility()
    self.render_current()
closeEvent
closeEvent(event: QCloseEvent) -> None

Shut every VTK view down before Qt destroys the widgets.

Without this, closing the window after a 3D view has rendered segfaults: the render window is torn down with its GL context still live. All three of them, not just the scene — the stage and the MAC bars have the same interactor, the same GL context and the same timers, and the scene-only version left them to take their chances (Brandon's crash report, 2026-08-30).

Source code in src/visualdynamics/gui/main_window.py
def closeEvent(self, event: QCloseEvent) -> None:
    """Shut every VTK view down before Qt destroys the widgets.

    Without this, closing the window after a 3D view has rendered
    segfaults: the render window is torn down with its GL context
    still live. All three of them, not just the scene — the stage
    and the MAC bars have the same interactor, the same GL context
    and the same timers, and the scene-only version left them to
    take their chances (Brandon's crash report, 2026-08-30).
    """
    self.set_playing(False)
    plotters = [self.scene.plotter, self.data_pane.waterfall_plotter,
                self._mac_bars_plotter_obj]
    self.scene.plotter = None
    self.data_pane.waterfall_plotter = None
    self._mac_bars_plotter_obj = None
    for plotter in plotters:
        if plotter is not None:
            with contextlib.suppress(Exception):  # never block closing
                plotter.close()
    super().closeEvent(event)
add_object
add_object(
    name: str,
    obj: Any,
    alternate: str | None = None,
    source: str | None = None,
    key: Any = None,
) -> str

Add an object, keeping names unique.

alternate is tried first on a clash. source is the file it was read from and key what that file's reader called it; both go on the tooltip, because the name carries neither.

Source code in src/visualdynamics/gui/main_window.py
def add_object(self, name: str, obj: Any, alternate: str | None = None,
               source: str | None = None,
               key: Any = None) -> str:
    """Add an object, keeping names unique.

    `alternate` is tried first on a clash. `source` is the file it was
    read from and `key` what that file's reader called it; both go on the
    tooltip, because the name carries neither.
    """
    if name in self.objects and alternate:
        name = alternate
    return self.show_object(self.project.add(name, obj),
                            source=source, key=key)
show_object
show_object(
    name: str,
    source: str | None = None,
    key: Any = None,
    select: bool = True,
) -> str

Give an object already in the project its row in the tree.

The store and the showing are separate: a project verb — a computation, a fit, a projection — puts the result in the project, and this is how the window catches up with it.

Source code in src/visualdynamics/gui/main_window.py
def show_object(self, name: str, source: str | None = None,
                key: Any = None, select: bool = True) -> str:
    """Give an object already in the project its row in the tree.

    The store and the showing are separate: a project verb — a
    computation, a fit, a projection — puts the result in the
    project, and this is how the window catches up with it.
    """
    obj = self.objects[name]
    item = QTreeWidgetItem([name])
    item.setIcon(0, object_icon(obj, units_defined=_units_defined(obj)))
    self.object_sources[name] = source
    self.object_keys[name] = key
    item.setToolTip(0, self._object_tooltip(name, obj))
    self._mark_computable(item, obj)
    item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable)
    # what the tree reads as "this row is an object", and so is
    # draggable between link groups: sub-items are parts of one
    item.setData(0, ROLE_DRAGGABLE, True)
    item.setData(0, ROLE_ORIGINAL_NAME, name)  # name before an edit
    item.setData(0, ROLE_REFERENCE, ('object', name, None))
    self._build_children(item, obj, name)
    self.test_item.addChild(item)
    self.test_item.setExpanded(True)
    self.refresh_compatibility()
    self._reorder_tree()    # canonical type order, then placeholders
    self._apply_type_links([name])
    self._paint_links()
    # a new object may be what a report figure was waiting for
    self._report_content_changed(settling=True)
    if select:
        self.tree.setCurrentItem(item)
        self.tree.clearSelection()
        item.setSelected(True)
    return name
current_reference
current_reference() -> tuple[str | None, Any, Any]

(kind, object, index-or-component) for the selected tree item.

Source code in src/visualdynamics/gui/main_window.py
def current_reference(self) -> tuple[str | None, Any, Any]:
    """(kind, object, index-or-component) for the selected tree item."""
    item = self.tree.currentItem()
    if item is None:
        return None, None, None
    reference = item.data(0, ROLE_REFERENCE)
    if reference is None:
        return None, None, None
    kind, name, detail = reference
    return kind, self.objects.get(name), detail
current_object
current_object() -> Any

The object owning the selection (a child resolves to its parent).

Source code in src/visualdynamics/gui/main_window.py
def current_object(self) -> Any:
    """The object owning the selection (a child resolves to its parent)."""
    return self.current_reference()[1]
selected_references
selected_references() -> list[tuple[str, str, Any, Any]]

[(kind, name, object, detail)] for every selected tree item.

Source code in src/visualdynamics/gui/main_window.py
def selected_references(self) -> list[tuple[str, str, Any, Any]]:
    """[(kind, name, object, detail)] for every selected tree item."""
    out = []
    for item in self.tree.selectedItems():
        reference = item.data(0, ROLE_REFERENCE)
        if reference is None:
            continue
        kind, name, detail = reference
        if kind == 'placeholder':   # a grey slot holds nothing yet
            continue
        if kind == 'test':  # the whole test: everything it holds
            out.extend(('object', key, value, None)
                       for key, value in self.objects.items())
            continue
        obj = self.objects.get(name)
        if obj is None:
            continue
        grid = self.record_grids.get(name) if kind == 'object' else None
        records = grid.selected_records() if grid is not None else []
        if records:
            # the grid's own selection is the sub-item selection — there
            # is nowhere else for the two to disagree — and its kind
            # keeps the tree's vocabulary: record, channel or mode
            out.extend((grid.kind, name, obj, i) for i in records)
        else:
            out.append((kind, name, obj, detail))
    return out
object_item
object_item(
    item: QTreeWidgetItem | None = None,
) -> QTreeWidgetItem | None

The object-level item for a selection (walks up to the test).

Source code in src/visualdynamics/gui/main_window.py
def object_item(self,
                item: QTreeWidgetItem | None = None
                ) -> QTreeWidgetItem | None:
    """The object-level item for a selection (walks up to the test)."""
    item = item or self.tree.currentItem()
    if item is self.test_item:
        return None
    while item is not None and item.parent() not in (None, self.test_item):
        item = item.parent()
    return item
delete_selected
delete_selected() -> None

Remove what is selected: entities while editing or when entity rows are picked in the tree, whole objects otherwise.

Source code in src/visualdynamics/gui/main_window.py
def delete_selected(self) -> None:
    """Remove what is selected: entities while editing or when entity
    rows are picked in the tree, whole objects otherwise."""
    if self.editing is not None:
        return self.delete_entity_rows()
    entities = self._selected_entities()
    if entities:
        return self._delete_entities(*entities)
    emptied = self._selected_components()
    if emptied:
        return self._empty_components(*emptied)
    if any(item is self.test_item for item in self.tree.selectedItems()):
        self._show_status('Select an object inside the test to delete it')
        return
    # Sub-items go before objects: a record, mode or channel picked in
    # the tree or in a grid deletes that record, not — as it used to —
    # the whole object it lives in. An object-level pick still deletes
    # the object, and outranks sub-item picks of the same object.
    doomed, subitems = {}, {}
    for kind, name, obj, detail in self.selected_references():
        if kind == 'object':
            doomed[name] = obj
        elif kind in ('record', 'mode', 'channel', 'photo'):
            subitems.setdefault(name, (obj, kind, set()))[2].add(detail)
    if subitems:
        # Sub-item picks make the whole keystroke about sub-items. A
        # co-selected object is context, not a target — a geometry is
        # selected so records can animate on it, and deleting one grid
        # record must not take the geometry with it. That happened: one
        # record and the whole geometry went in a single keystroke.
        doomed = {}
    if not doomed and not subitems:
        return
    removed = []
    for name, (obj, kind, picks) in subitems.items():
        deleter = {'record': 'delete_records', 'mode': 'delete_modes',
                   'channel': 'delete_channels',
                   'photo': 'delete_photos'}[kind]
        line = self._deletion_line(name, obj, deleter, picks)
        try:
            getattr(obj, deleter)(picks)
        except ValueError as refusal:
            self._show_status(f'{name}: {refusal}')
            return
        self.project.journal.append(line)
        self._refresh_item(self._item_for_object(name), obj)
        removed.append(f'{len(picks)} {kind}'
                       + ('s' if len(picks) != 1 else '')
                       + f' from {name}')
    if doomed:
        # One selection change for the whole batch, not one per
        # removal: taking rows out one at a time promotes a
        # neighbour to current each time, and the handler
        # re-rendered each survivor's view only for the next
        # removal to kill it — the specification plot blinked once
        # per object on a delete-all (Brandon, 2026-08-31). The
        # links and placeholders settle once too, after the loop.
        with QSignalBlocker(self.tree):
            self.tree.clearSelection()
            self.tree.setCurrentItem(None)
            for name in doomed:
                self._remove_object(name, settle=False)
        self._paint_links()
        self._refresh_placeholders()
        removed.extend(doomed)
    self.refresh_compatibility()
    self.render_current()
    self._show_status('Removed ' + ', '.join(removed))
acts_for
acts_for(names=None)

[(verb, label, icon, handler, tooltip)] the selection can take, in the bar's order.

names is the selection; left out, it is read from the tree, a record pick counting as its object. The project row selected offers the one act the project itself has, its report; a stale object offers Recompute first, ahead of anything else it can do.

Source code in src/visualdynamics/gui/main_window.py
def acts_for(self, names=None):
    """[(verb, label, icon, handler, tooltip)] the selection can
    take, in the bar's order.

    `names` is the selection; left out, it is read from the tree,
    a record pick counting as its object. The project row selected
    offers the one act the project itself has, its report; a stale
    object offers Recompute first, ahead of anything else it can do.
    """
    if names is None:
        if self.test_item.isSelected():
            return [('generate_report', 'Generate Report', 'report',
                     self.generate_report_act,
                     ('Generate the report this project\'s type '
                      'calls for'))]
        names = list(dict.fromkeys(
            name for kind, name, _obj, _detail
            in self.selected_references()
            if kind in ('object', 'record')))
    names = [name for name in names if name in self.objects]
    if not names:
        return []
    applicable = dict(self.project.selection_verbs(*names))
    acts = [(verb, label, icon, getattr(self, handler),
             applicable[verb])
            for verb, label, icon, handler in self.ACTS
            if verb in applicable]
    if len(names) == 1 and names[0] in self._stale:
        acts.insert(0, ('refresh', 'Recompute', 'refresh',
                        self.refresh_selected,
                        (f'Settings changed: {self._stale[names[0]]}. '
                         'Recompute this object from its source')))
    return acts
refresh_selected
refresh_selected() -> None

Recompute the selected stale object — the bar's Recompute, the same act as the badge's click.

Source code in src/visualdynamics/gui/main_window.py
def refresh_selected(self) -> None:
    """Recompute the selected stale object — the bar's Recompute,
    the same act as the badge's click."""
    item = self.object_item()
    if item is not None:
        self.refresh_object(item.text(0))
generate_report_act
generate_report_act() -> None

The bar's Generate Report: the typed report at once, or the choice of templates for an untyped project — and the templates the user has saved, beside the built-in ones, whenever there are any (2026-09-08).

Source code in src/visualdynamics/gui/main_window.py
def generate_report_act(self) -> None:
    """The bar's Generate Report: the typed report at once, or the
    choice of templates for an untyped project — and the templates
    the user has saved, beside the built-in ones, whenever there
    are any (2026-09-08)."""
    from ..io.report_template import saved_templates

    saved = saved_templates()
    if self.project_type and not saved:
        # the type already says which report this project is for,
        # so there is nothing to choose — one click, one report
        self.generate_typed_report()
        return
    menu = QMenu(self)
    if self.project_type:
        menu.addAction(f'Generate {self.project_type} Report',
                       self.generate_typed_report)
    else:
        for label, template in (('&Modal Test', 'modal'),
                                ('&Random Vibration', 'random'),
                                ('&Transient', 'transient'),
                                ('&Shock', 'shock'),
                                ('S&ine Sweep', 'sine'),
                                ('S&ystem ID', 'sysid'),
                                ('&Empty', 'empty')):
            menu.addAction(f'Generate {label} Report',
                           lambda _checked=False, t=template:
                           self.generate_report(t))
    if saved:
        menu.addSeparator()
        for label, path in saved:
            menu.addAction(f'Generate {label} Report (saved template)',
                           lambda _checked=False, t=str(path):
                           self.generate_report(t))
    self._pop_menu(menu)
refresh_object
refresh_object(name: str) -> None

Recompute a stale object in place — the badge's click.

Source code in src/visualdynamics/gui/main_window.py
def refresh_object(self, name: str) -> None:
    """Recompute a stale object in place — the badge's click."""
    reason = self._stale.get(name, '')
    try:
        self.project.refresh(name)
    except ValueError as refusal:
        self._show_status(f'{name}: {refusal}')
        return
    obj = self.objects[name]
    item = self._item_for_object(name)
    if item is not None:
        item.setToolTip(0, self._object_tooltip(name, obj))
        self._build_children(item, obj, name)
    self._refresh_stale_badges()
    self._report_content_changed()
    self.render_current()
    followed = [other for other in self._stale
                if self.provenance_source(other) == name]
    told = (f' — {", ".join(followed)} now stale in turn'
            if followed else '')
    self._show_status(
        f'{name} recomputed'
        + (f' ({reason})' if reason else '') + told)
linked_group
linked_group(name: str) -> list[str] | None

The linked member names beside name, or None.

Source code in src/visualdynamics/gui/main_window.py
def linked_group(self, name: str) -> list[str] | None:
    """The linked member names beside `name`, or None."""
    group = self._group_of(name)
    return None if group is None else group['members']
link_role(name: str) -> str | None

The role of name's link group — 'Basis', or None for an unroled or unlinked object. The Basis group defines the DOF space comparisons happen in: other sets project onto its DOFs, its modes are the MAC rows and the frequency-error baseline.

Source code in src/visualdynamics/gui/main_window.py
def link_role(self, name: str) -> str | None:
    """The role of `name`'s link group — 'Basis', or None for an
    unroled or unlinked object. The Basis group defines the DOF
    space comparisons happen in: other sets project onto its DOFs,
    its modes are the MAC rows and the frequency-error baseline."""
    group = self._group_of(name)
    return None if group is None else group['role']
set_link_role(name: str, role: str | None) -> None

Declare a link group the Basis of comparisons — explicit, so nothing downstream has to guess which group is which.

Source code in src/visualdynamics/gui/main_window.py
def set_link_role(self, name: str, role: str | None) -> None:
    """Declare a link group the Basis of comparisons — explicit,
    so nothing downstream has to guess which group is which."""
    group = self._group_of(name)
    if group is None:
        return
    # the role means one thing: taking it takes it from any other
    if role is not None:
        for other in self.links:
            if other is not group and other['role'] == role:
                other['role'] = None
    group['role'] = role
    self._reorder_tree()      # the Basis group reads first
    self._paint_links()
    self._show_status(
        f'{", ".join(group["members"])}: '
        + ('marked as the Basis — comparisons happen in this '
           'group\'s DOFs' if role else 'Basis cleared'))
linked_geometry
linked_geometry(name: str) -> tuple[str, Geometry] | None

(geometry name, geometry) linked with name, or None — explicit knowledge, never a guess.

Source code in src/visualdynamics/gui/main_window.py
def linked_geometry(self,
                    name: str) -> tuple[str, Geometry] | None:
    """(geometry name, geometry) linked with `name`, or None —
    explicit knowledge, never a guess."""
    members = self.linked_group(name)
    if members is None:
        return None
    return next(((member, self.objects[member])
                 for member in members
                 if isinstance(self.objects.get(member), Geometry)),
                None)
unlink_selected() -> None

Take the selected objects out of their groups; a group left with one member dissolves.

Source code in src/visualdynamics/gui/main_window.py
def unlink_selected(self) -> None:
    """Take the selected objects out of their groups; a group left
    with one member dissolves."""
    names = self._selected_object_names()
    if not names:
        return
    self.project.unlink(*names)
    self._links_changed()
    self._show_status('Unlinked ' + ', '.join(names))
set_project_type
set_project_type(project_type: str | None) -> None

Declare what kind of test this project is — the tree then shows a grey slot for everything that kind of report expects.

The type never pre-selects a reading: a time history opens plain whatever the project is (Brandon, 2026-08-30).

Source code in src/visualdynamics/gui/main_window.py
def set_project_type(self, project_type: str | None) -> None:
    """Declare what kind of test this project is — the tree then
    shows a grey slot for everything that kind of report expects.

    The type never pre-selects a reading: a time history opens
    plain whatever the project is (Brandon, 2026-08-30).
    """
    self.project_type = project_type or None
    # an act like any other, journalled as the assignment a script
    # makes — settling in place, since imports may restate it
    journal = self.project.journal
    line = f'project.project_type = {self.project_type!r}'
    if journal and journal[-1].startswith('project.project_type = '):
        journal[-1] = line
    elif self.project_type is not None:
        journal.append(line)
    self._refresh_placeholders()
    self._apply_type_links()
    if self.project_type:
        missing = missing_expectations(self.project_type, self.objects,
                                       self._sides())
        self._show_status(
            f'{self.project_type} project — '
            + (f'{len(missing)} expected '
               f'object{"s" * (len(missing) != 1)} still missing'
               if missing else 'everything a report needs is here'))
    else:
        self._show_status('Project type cleared')
delete_entity_rows
delete_entity_rows() -> None

Delete the rows selected in the edit table.

Source code in src/visualdynamics/gui/main_window.py
def delete_entity_rows(self) -> None:
    """Delete the rows selected in the edit table."""
    if self.editing is None:
        return
    name, component = self.editing
    geometry = self.objects.get(name)
    rows = sorted({index.row() for index
                   in self.table.selectionModel().selectedIndexes()})
    if not rows or geometry is None:
        return
    keys = [_entity_key(geometry, component, row) for row in rows]
    self._delete_entities(name, component, keys)
define_units
define_units() -> None

Declare units for what is selected.

With individual records selected, only those get units; selecting an object (or its category) covers all of its records.

Source code in src/visualdynamics/gui/main_window.py
def define_units(self) -> None:
    """Declare units for what is selected.

    With individual records selected, only those get units; selecting an
    object (or its category) covers all of its records.
    """
    targets = {}
    for kind, name, obj, detail in self.selected_references():
        if isinstance(obj, ChannelTable):
            continue
        if isinstance(obj, ShapeSet):
            targets.setdefault(name, {'object': obj, 'records': set(),
                                      'whole': True})['whole'] = True
            continue
        entry = targets.setdefault(name, {'object': obj, 'records': set(),
                                          'whole': False})
        if kind == 'record':
            entry['records'].add(detail)
        else:
            entry['whole'] = True
    if not targets:
        self._show_status(
            'Select an object or channels inside the test to define units')
        return

    # every kind of object declares its units the same way, in the pane
    name = next(iter(targets))
    entry = targets[name]
    records = (None if entry['whole'] or not entry['records']
               else sorted(entry['records']))
    self.show_units_panel(name, entry['object'], records)
    if len(targets) > 1:
        # the pane shows one object; it stays open, so the rest are a
        # matter of selecting them next rather than a queue of dialogs
        self._show_status(
            f'Defining imported units for {name}; select the other '
            f'{len(targets) - 1} separately')
    self.render_current()
set_active_geometry
set_active_geometry(name: str | None = None) -> None

Choose the geometry every other object is checked against.

Source code in src/visualdynamics/gui/main_window.py
def set_active_geometry(self, name: str | None = None) -> None:
    """Choose the geometry every other object is checked against."""
    if name is None:
        item = self.object_item()
        name = item.text(0) if item is not None else None
    if not isinstance(self.objects.get(name), Geometry):
        self._show_status('Select a geometry to make it active')
        return
    self.active_geometry = name
    journal = self.project.journal
    line = f'project.active_geometry = {name!r}'
    if journal and journal[-1].startswith('project.active_geometry = '):
        journal[-1] = line
    else:
        journal.append(line)
    self.refresh_compatibility()
    self._show_status(f'{name} is now the active geometry')
refresh_compatibility
refresh_compatibility() -> None

Re-check the test and repaint the tree.

Runs when the project changes — import, delete, rename, or a new active geometry — not on selection or render.

Source code in src/visualdynamics/gui/main_window.py
def refresh_compatibility(self) -> None:
    """Re-check the test and repaint the tree.

    Runs when the project changes — import, delete, rename, or a new
    active geometry — not on selection or render.
    """
    if self.active_geometry not in self.objects:
        self.active_geometry = next(
            (name for name, obj in self.objects.items()
             if isinstance(obj, Geometry)), None)
    self.report = check_compatibility(self.objects,
                                      self.active_geometry,
                                      links=self.links)
    self._paint_compatibility()
show_units_panel
show_units_panel(
    name: str,
    obj: Any,
    records: Sequence[int] | None = None,
) -> None

Open the units pane on one data object, leaving the plot up.

An FRF gets two tables — its response channels and its reference channels — because a matrix of N×M records has only N+M channel units to name. Everything else fills the first table alone.

Source code in src/visualdynamics/gui/main_window.py
def show_units_panel(self, name: str, obj: Any,
                     records: Sequence[int] | None = None) -> None:
    """Open the units pane on one data object, leaving the plot up.

    An FRF gets two tables — its response channels and its reference
    channels — because a matrix of N×M records has only N+M channel
    units to name. Everything else fills the first table alone.
    """
    two_sided = isinstance(obj, DataArray) and obj.needs_reference
    if two_sided:
        models = frf_units_models(obj, records, self)
    else:
        models = (units_table_model(obj, records, self),)
        self.units_reference_table.setModel(None)
    wanted = 40
    for table, model in zip(
            (self.units_table, self.units_reference_table), models):
        table.setModel(model)
        model.edit_rejected.connect(self._show_status)
        model.dataChanged.connect(self._units_declared)
        wanted += self._size_units_columns(table, model)
    for widget in (self.units_response_caption,
                   self.units_reference_caption,
                   self.units_reference_table):
        widget.setVisible(two_sided)
    total = sum(self.main_split.sizes()) or self.width()
    self.main_split.setSizes([max(total - wanted, 320), wanted])
    # "Units" alone would read as the units things are shown and written
    # in, which is the selector in the status bar. These are the units the
    # values arrived in, and naming them converts once, to SI.
    self.units_title.setText(f'Imported Units — {name}')
    self.units_note.setText(
        'What the values were recorded in — naming a unit converts them '
        'once. Copy, paste, Delete to clear, and right-click to set '
        'several at a time. Drag the corner handle to fill down — or '
        'double-click it to fill to the end.')
    self.units_target = (name, obj)
    self.units_panel.show()
rename_selected
rename_selected() -> None

Start inline editing of the selected name.

The project's own row included. top_level_item walks up to the project and answers None when it is already there — which is right for finding an object's row and wrong here, and left Rename silently doing nothing on the one row that carries the project's name.

Source code in src/visualdynamics/gui/main_window.py
def rename_selected(self) -> None:
    """Start inline editing of the selected name.

    The project's own row included. `top_level_item` walks *up* to
    the project and answers None when it is already there — which is
    right for finding an object's row and wrong here, and left
    Rename silently doing nothing on the one row that carries the
    project's name.
    """
    current = self.tree.currentItem()
    item = (self.test_item if current is self.test_item
            else self.top_level_item())
    if item is None:
        return
    # A photo picked in the grid is the thing being renamed, not the
    # object holding it — the grid's cell selection *is* the sub-item
    # selection. Renaming the object instead is not a refusal, it is
    # the wrong rename carried out silently, which is how this read
    # as "renaming a photo does not work".
    name = item.text(0)
    grid = self.record_grids.get(name)
    if isinstance(self.objects.get(name), Photos) and grid is not None:
        picked = grid.selected_records()
        if len(picked) == 1:
            self._rename_photo(name, picked[0])
            return
    self.tree.setCurrentItem(item)
    self.tree.editItem(item, 0)
rename_object
rename_object(old: str, new: str) -> str

Rename an object from code, numbering a taken name the way Project.add does; returns the name used.

Source code in src/visualdynamics/gui/main_window.py
def rename_object(self, old: str, new: str) -> str:
    """Rename an object from code, numbering a taken name the way
    `Project.add` does; returns the name used."""
    unique, n = new, 1
    while unique in self.objects:
        n += 1
        unique = f'{new} ({n})'
    item = self._item_for_object(old)
    self.tree.blockSignals(True)
    try:
        item.setText(0, unique)
        self._carry_rename(item, old, unique)
    finally:
        self.tree.blockSignals(False)
    return unique
edit_entities
edit_entities() -> None

Edit a geometry's nodes, coordinate systems, tracelines, elements or blocks in a table beside the model.

Source code in src/visualdynamics/gui/main_window.py
def edit_entities(self) -> None:
    """Edit a geometry's nodes, coordinate systems, tracelines,
    elements or blocks in a table beside the model."""
    kind, obj, detail = self.current_reference()
    component = detail if kind == 'component' else ENTITY_COMPONENT.get(kind)
    if not isinstance(obj, Geometry) or component not in ENTITY_TABLES:
        self._show_status(
            'Select nodes, coordinate systems, tracelines, elements or '
            'blocks to edit them')
        return
    item = self.object_item()
    self.editing = (item.text(0), component)
    self.views.setOrientation(Qt.Orientation.Horizontal)
    model = self._set_table_model(
        ENTITY_TABLES[component](obj, self.unit_system, self))
    model.dataChanged.connect(self._edited_geometry)
    self.table.selectionModel().selectionChanged.connect(
        self._edit_selection_changed)
    self._show_views(three_d=True, table=True)
    self._draw_edit_selection()
    self.add_action.setVisible(True)
    # a block is not placed in space, so its + adds a row outright
    # rather than arming a mode that waits for a click in the view
    self.add_action.setToolTip('Add an empty block' if component == 'blocks'
                               else 'Add items by clicking in the view')
    self._begin_picking(obj, self._picking_component())
    self._update_toolbar_actions()
    self._show_status(
        f'Editing {component.replace("_", " ")} of {self.editing[0]} — '
        'select rows to highlight them; Escape or reselecting the tree '
        'leaves editing')
set_add_mode
set_add_mode(enabled: bool) -> None

Toggle creating things by clicking in the 3D view.

Source code in src/visualdynamics/gui/main_window.py
def set_add_mode(self, enabled: bool) -> None:
    """Toggle creating things by clicking in the 3D view."""
    enabled = bool(enabled) and self.editing is not None
    if enabled and self.editing[1] == 'blocks':
        # Nothing to click: a block is a name over elements, not a
        # place. The button adds one and comes straight back up —
        # arming a mode that could never be satisfied would be a
        # control that looks live and does nothing. The button comes
        # back up first, because that re-enters here and would
        # otherwise overwrite what was just said in the status bar.
        self.add_action.setChecked(False)
        self._add_block()
        return
    if not enabled and self._picked_nodes:
        self._commit_picked_nodes()
    self.add_mode = enabled
    self._picked_nodes = []
    self._draw_picked()
    if self.add_action.isChecked() != enabled:
        self.add_action.setChecked(enabled)
    self._update_element_type_actions()
    if self.editing is None:
        return
    geometry_name, component = self.editing
    geometry = self.objects.get(geometry_name)
    if geometry is not None:
        # tracelines and elements are built from nodes, so pick nodes
        self._begin_picking(geometry, self._picking_component())
    self._show_status(self._add_mode_hint() if enabled
                      else f'Editing {component.replace("_", " ")}')
set_rotate_mode
set_rotate_mode(enabled: bool) -> None

Show the rings, and take over the mouse while one is dragged.

Source code in src/visualdynamics/gui/main_window.py
def set_rotate_mode(self, enabled: bool) -> None:
    """Show the rings, and take over the mouse while one is dragged."""
    enabled = bool(enabled) and self._turnable_row() is not None
    self._rotating = None
    if enabled:
        self._begin_rotating()
    else:
        self._end_rotating()
    self._update_rotate_actions()
reset_rotation
reset_rotation() -> None

Put the frame back to no rotation, keeping where it sits.

Source code in src/visualdynamics/gui/main_window.py
def reset_rotation(self) -> None:
    """Put the frame back to no rotation, keeping where it sits."""
    row, matrix = self._rotating_frame()
    if matrix is None:
        return
    geometry = self.objects[self.editing[0]]
    geometry.cs_matrix[row] = identity_frame(matrix)
    self.angle_box.blockSignals(True)
    self.angle_box.setValue(0.0)
    self.angle_box.blockSignals(False)
    self.table.model().refresh_row(row)
    self._draw_rings()
    self._draw_live_triad(row, geometry.cs_matrix[row])
    self._show_status('Rotation reset')
hover_at
hover_at(x: float, y: float) -> int | None

Light up whatever is under this pixel; returns the entity.

Source code in src/visualdynamics/gui/main_window.py
def hover_at(self, x: float, y: float) -> int | None:
    """Light up whatever is under this pixel; returns the entity."""
    entity = self._picker.pick(x, y)
    if entity == self._hovered:
        return entity        # nothing changed, so nothing to redraw
    self._hovered = entity
    self._draw_hover()
    return entity
select_entity
select_entity(
    entity: int | None, extend: bool = False
) -> int | None

Select the table row for an entity picked in the 3D view.

The same rule for every kind of entity: a plain click selects only what was clicked, the modifier adds to the selection, and clicking something already selected takes it back out.

Source code in src/visualdynamics/gui/main_window.py
def select_entity(self, entity: int | None,
                  extend: bool = False) -> int | None:
    """Select the table row for an entity picked in the 3D view.

    The same rule for every kind of entity: a plain click selects only
    what was clicked, the modifier adds to the selection, and clicking
    something already selected takes it back out.
    """
    if self.editing is None or entity is None:
        return None
    name, component = self.editing
    geometry = self.objects.get(name)
    row = _row_for_entity(geometry, component, entity)
    if row is None:
        return None
    if not extend:
        mode = QItemSelectionModel.SelectionFlag.ClearAndSelect
    elif row in {index.row() for index
                 in self.table.selectionModel().selectedIndexes()}:
        mode = QItemSelectionModel.SelectionFlag.Deselect
    else:
        mode = QItemSelectionModel.SelectionFlag.Select
    model = self.table.model()
    self.table.selectionModel().select(
        model.index(row, 0),
        mode | QItemSelectionModel.SelectionFlag.Rows)
    self.table.scrollTo(model.index(row, 0))
    return row
about
about() -> None

The version and what it is: an alpha, to be checked, with the contact address. The one place a user can read the version without the network or a relaunch.

Source code in src/visualdynamics/gui/main_window.py
def about(self) -> None:
    """The version and what it is: an alpha, to be checked, with
    the contact address. The one place a user can read the version
    without the network or a relaunch."""
    from .. import __version__
    from .disclaimer import TEXT

    box = QMessageBox(self)
    box.setWindowTitle('About Visual Dynamics')
    box.setIconPixmap(self.windowIcon().pixmap(64, 64))
    box.setText(f'<b>Visual Dynamics {__version__}</b><br>'
                'A units-aware toolset for structural dynamics test '
                'work.<br>© 2026 Brandon Zwink — see LICENSE for the '
                'terms.')
    box.setInformativeText(TEXT)
    box.setTextFormat(Qt.TextFormat.RichText)
    box.setStandardButtons(QMessageBox.StandardButton.Ok)
    box.setModal(False)
    box.show()
    self.about_box: QMessageBox | None = box
check_for_updates
check_for_updates() -> None

File → Check for Updates: ask visualdynamics.org for the latest version and say what it found.

A check, never an update — update.py explains the line: an updater that runs what it fetched without verifying a signature is remote code execution with a friendly name, and there is no signing identity yet. So a newer version is offered as a page to open, in the browser, where the release is (and where a private release wants the user's own login). The fetch runs off the main thread: the check is the least important thing the application does and must never be why it is slow.

Source code in src/visualdynamics/gui/main_window.py
def check_for_updates(self) -> None:
    """File → Check for Updates: ask visualdynamics.org for the
    latest version and say what it found.

    A check, never an update — `update.py` explains the line: an
    updater that runs what it fetched without verifying a
    signature is remote code execution with a friendly name, and
    there is no signing identity yet. So a newer version is
    offered as a page to open, in the browser, where the release
    is (and where a private release wants the user's own login).
    The fetch runs off the main thread: the check is the least
    important thing the application does and must never be why it
    is slow.
    """
    import threading

    from .. import update

    self._show_status('Checking for updates…')
    if not getattr(self, '_update_wired', False):
        self._update_answer.connect(self._report_update)
        self._update_wired = True
    threading.Thread(target=lambda: self._update_answer.emit(
        update.fetch()), daemon=True).start()
dragEnterEvent
dragEnterEvent(event: QDragEnterEvent) -> None

Anywhere on the window will do.

Dropping on the tree alone made a target of whatever the tree happened to be that moment — and on a fresh window it is sized to no content at all, a strip about 90 px wide. Missing it does nothing at all, which reads as the drop being ignored, so the file gets dragged over again. The window is the thing being aimed at; let it take the file.

Source code in src/visualdynamics/gui/main_window.py
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
    """Anywhere on the window will do.

    Dropping on the tree alone made a target of whatever the tree
    happened to be that moment — and on a fresh window it is sized to
    no content at all, a strip about 90 px wide. Missing it does
    nothing at all, which reads as the drop being ignored, so the
    file gets dragged over again. The window is the thing being
    aimed at; let it take the file.
    """
    from .project_tree import trace_drag

    trace_drag('window', 'enter', event.mimeData())
    if event.mimeData().hasUrls() and self._dropped_files(event.mimeData()):
        event.setDropAction(Qt.DropAction.CopyAction)
        event.acceptProposedAction()
    else:
        super().dragEnterEvent(event)
event
event(found: QEvent) -> bool

Keyboard focus is parked while the window is inactive.

macOS 26 with Qt 6.11 discards a Finder drop released over the rect of the widget that held focus when the app deactivated: draggingEntered and draggingUpdated are answered Copy, and the release still arrives as draggingExited — no drop, no error, nothing. The tree is the first focusable widget, so on a fresh window it held focus and the one dead spot in the window was the very widget whose status line says "drag files onto the tree". Proven by swizzling the NSView's dragging methods and toggling nothing but focus; the full hunt is in the log for commit that introduced this.

Three shapes of this fix failed first, each for a reason worth keeping:

  • Parking when the drag enters is too late. The drag's source deactivated the app before the first enter, and the platform state that kills the drop is set by then.
  • clearFocus() at deactivation is in time but does nothing: focus-to-nobody leaves the platform's input state armed. Only a genuine handoff to another widget runs the full teardown.
  • Disabling input methods on the tree changes nothing — the poison is the focus itself, not the input context.

So focus is handed to a zero-size widget whose rect nothing can hit, and handed back on activation. An inactive window has no keyboard, so nothing is lost in between.

Source code in src/visualdynamics/gui/main_window.py
def event(self, found: QEvent) -> bool:
    """Keyboard focus is parked while the window is inactive.

    macOS 26 with Qt 6.11 discards a Finder drop released over the
    rect of the widget that held focus when the app deactivated:
    draggingEntered and draggingUpdated are answered Copy, and the
    release still arrives as draggingExited — no drop, no error,
    nothing. The tree is the first focusable widget, so on a fresh
    window it held focus and the one dead spot in the window was
    the very widget whose status line says "drag files onto the
    tree". Proven by swizzling the NSView's dragging methods and
    toggling nothing but focus; the full hunt is in the log for
    commit that introduced this.

    Three shapes of this fix failed first, each for a reason worth
    keeping:

    - Parking when the drag *enters* is too late. The drag's source
      deactivated the app before the first enter, and the platform
      state that kills the drop is set by then.
    - `clearFocus()` at deactivation is in time but does nothing:
      focus-to-nobody leaves the platform's input state armed. Only
      a genuine handoff to another widget runs the full teardown.
    - Disabling input methods on the tree changes nothing — the
      poison is the focus itself, not the input context.

    So focus is *handed* to a zero-size widget whose rect nothing
    can hit, and handed back on activation. An inactive window has
    no keyboard, so nothing is lost in between.
    """
    if found.type() == QEvent.Type.WindowDeactivate:
        if QApplication.activePopupWidget() is not None:
            # deactivated by one of our own popups — a combo's
            # drop-down list is its own window on macOS 26 — and
            # parking here hands focus away from the editor, which
            # closes the popup the instant it opens (Brandon,
            # 2026-08-30, the units drop-down). The park exists
            # for drags arriving from *another app*; while our
            # popup is up, the app never lost the stage.
            return super().event(found)
        focused = self.focusWidget()
        if focused is not None and focused is not self._focus_park:
            self._parked_focus = focused
            self._focus_park.setFocus(
                Qt.FocusReason.OtherFocusReason)
    elif found.type() == QEvent.Type.WindowActivate:
        parked, self._parked_focus = self._parked_focus, None
        if parked is not None and self.focusWidget() is self._focus_park:
            try:
                if parked.isVisible():
                    parked.setFocus(Qt.FocusReason.OtherFocusReason)
            except RuntimeError:
                pass            # it died while the window was away
    return super().event(found)
import_paths
import_paths(paths: Sequence[str]) -> list[str]

Import several files, reporting any failures once at the end.

Dropped images are not their own objects: they land together in the project's Photos object, created on first use.

Source code in src/visualdynamics/gui/main_window.py
def import_paths(self, paths: Sequence[str]) -> list[str]:
    """Import several files, reporting any failures once at the end.

    Dropped images are not their own objects: they land together in
    the project's Photos object, created on first use.
    """
    if self._importing:
        # the progress ticks flush paints by pumping the queue, and
        # a drop landing mid-import queues a second import that the
        # pump would start *inside* the first — the very
        # re-entrancy all the deferral exists to prevent. It waits
        # its turn instead.
        QTimer.singleShot(100, lambda: self.import_paths(list(paths)))
        return []
    self._importing = True
    # the strip takes over from the temporary message: same words,
    # left side, immune to nothing painting while the loop blocks
    self.statusBar().clearMessage()
    self._import_label.setText(self._status_text)
    self._import_strip.show()
    try:
        return self._import_paths(paths)
    finally:
        self._importing = False
        self._import_strip.hide()
        # the one render the suppressed selection changes add up to.
        # It narrates what it drew, which must not shout down what
        # the import said — a type announcement outranks "8
        # channels on the stage" — so the import's last word is
        # restated after it.
        told = self._status_text
        self.render_current()
        # whatever was said last mid-import — a type announcement,
        # a refusal — went to the strip's label; restate it the
        # normal way so hiding the strip does not eat it
        self._show_status(told)
save_test
save_test() -> None

Save the whole project — every object, under its name — one file.

Source code in src/visualdynamics/gui/main_window.py
def save_test(self) -> None:
    """Save the whole project — every object, under its name — one file."""
    name = self.test_item.text(0)
    path, _ = QFileDialog.getSaveFileName(
        self, 'Save Project', f'{name}.vdyn', 'Visual Dynamics files (*.vdyn)')
    if not path:
        return
    # through the verb, not io directly: the verb carries the
    # provenance records (the direct call dropped them, and a
    # GUI-saved project reopened with no staleness bookkeeping)
    # and journals the save like any other act (Brandon,
    # 2026-08-30)
    self.project.name = name
    self.project.save(path)
    count = len(self.objects)
    self._show_status(
        f'Saved {name} ({count} object{"s" * (count != 1)}) to '
        f'{os.path.basename(path)}')
copy_selected
copy_selected() -> None

Cmd/Ctrl+C in the tree: the selected objects onto the clipboard, as objects and as files.

Source code in src/visualdynamics/gui/main_window.py
def copy_selected(self) -> None:
    """Cmd/Ctrl+C in the tree: the selected objects onto the
    clipboard, as objects and as files."""
    names = self.tree.copy_selected()
    if names == [PROJECT_ROW]:
        self._show_status('Copied the project — paste into a folder '
                          'to save it as a .vdyn')
    elif names:
        self._show_status(
            f'Copied {len(names)} object{"s" * (len(names) != 1)} — '
            'paste here to duplicate, or into a folder to export')
    else:
        self._show_status('Select objects in the tree to copy them')
paste_objects
paste_objects() -> None

Cmd/Ctrl+V in the tree: whatever the clipboard holds that this tree can take.

Source code in src/visualdynamics/gui/main_window.py
def paste_objects(self) -> None:
    """Cmd/Ctrl+V in the tree: whatever the clipboard holds that
    this tree can take."""
    if not self.tree.paste():
        self._show_status('Nothing on the clipboard to paste here')
save_selected
save_selected() -> None

Write the selected object, in whichever form is asked for.

One verb, because Save As and Export were the same act with two names — pick an object, pick a file, write it — and a user who wanted a UNV had to know that the second menu entry existed and that the first would not offer it.

The format is the dialog's file-type list, which is where a save dialog has always asked. .vdyn is first because it is the only form that comes back whole; the rest are what this particular object can be written as, so a mode shape is never offered a format that cannot hold one — said in the list rather than in an error afterwards.

Source code in src/visualdynamics/gui/main_window.py
def save_selected(self) -> None:
    """Write the selected object, in whichever form is asked for.

    One verb, because *Save As* and *Export* were the same act with
    two names — pick an object, pick a file, write it — and a user
    who wanted a UNV had to know that the second menu entry existed
    and that the first would not offer it.

    The format is the dialog's file-type list, which is where a save
    dialog has always asked. `.vdyn` is first because it is the only
    form that comes back whole; the rest are what this particular
    object can be written as, so a mode shape is never offered a
    format that cannot hold one — said in the list rather than in an
    error afterwards.
    """
    if self.tree.currentItem() is self.test_item:
        self.save_test()
        return
    obj = self.current_object()
    if obj is None:
        QMessageBox.information(self, 'Save', 'Select an object to save.')
        return
    native = 'Visual Dynamics object (*.vdyn)'
    available = io.exporters(obj)
    filters = ';;'.join([native, *(f'{e.description} (*{e.suffix})'
                                   for e in available)])
    path, chosen = QFileDialog.getSaveFileName(self, 'Save As', '', filters)
    if not path:
        return
    exporter = next((e for e in available
                     if f'{e.description} (*{e.suffix})' == chosen), None)
    if exporter is None:
        if not path.endswith('.vdyn'):
            path += '.vdyn'
        io.save(obj, path)
        self._show_status(f'Saved {path}')
        return
    self._export_object(obj, path, exporter)
render_current
render_current() -> None

Render everything selected: geometries overlay, curves share axes.

Source code in src/visualdynamics/gui/main_window.py
def render_current(self) -> None:
    """Render everything selected: geometries overlay, curves share axes."""
    self._update_toolbar_actions()
    # the frames and the shock windows belong to the plot that was
    # up; whatever is drawn next puts its own back if it wants them
    self._clear_averaging()
    self._clear_shocks()
    self._clear_filtering()
    self._clear_truncation()
    self._clear_octave()
    # and so does the 3-D surface: the waterfall path raises it
    # again itself, so a photo, a report, a fit or an empty
    # selection never inherits the last selection's 3-D view. Here
    # rather than in _show_views because the empty-selection path
    # exits before _show_views runs
    self.data_pane.show_waterfall(False)
    references = self.selected_references()
    # the fit owns the panes while its FRF stays selected; looking at
    # anything else ends the fit (the fitted modes are already in the
    # project — nothing is lost by leaving)
    if self.fit is not None:
        selected = {name for _kind, name, _obj, _detail in references}
        if selected and selected <= {self.fit_name, self.fit_object_name}:
            return
        self.stop_fitting()
    # one FRF and one shape set, both whole, read two ways — the
    # Edit Fit / Resynthesis buttons choose, and the choice sticks.
    # Sub-item picks keep their own meanings (a mode pick beside an
    # FRF is always the synthesis overlay), so they do not enter here.
    chosen = {name: obj for _kind, name, obj, _detail in references}
    is_pair = (len(chosen) == 2
               and sum(isinstance(obj, Frf)
                       for obj in chosen.values()) == 1
               and sum(isinstance(obj, ShapeSet)
                       for obj in chosen.values()) == 1)
    # mode or record picks keep the overlay showing whatever the
    # toggle last said — browsing a truncated synthesis must not keep
    # snapping into the fit — but the Edit button stays offered
    self._pair_selected = is_pair and all(
        kind == 'object' for kind, *_rest in references)
    self._pair_with_picks = is_pair and all(
        kind in ('object', 'mode', 'record')
        for kind, *_rest in references)
    if (self._pair_selected and self.pair_mode == 'fit'
            and self.fit is None):
        self.start_modal_fit()
        return
    # the pane edits what is selected; looking at something else puts it
    # away, and so does deleting the object out from under it
    if self.units_target is not None and (
            self.objects.get(self.units_target[0])
            is not self.units_target[1]
            or self.units_target[0] not in {
                name for _kind, name, _obj, _detail in references}):
        self.close_units_panel()
    # The project row alone shows nothing on the right. Drawn as
    # "everything it holds" it was every geometry overlaid, fifty
    # curves on one axis and the channel table, and the act a person
    # came to it for — Generate Report — sat a third of the way down
    # the window on the time data's bar (Brandon, 2026-09-09: "I'd
    # almost rather see nothing in the right screen when the top
    # level project is selected but still see the generate report
    # option in the toolbar"). So the panes clear, the 3-D view
    # holds the space with the project's own acts on its bar, and
    # the status line says what the project holds. The row still
    # *means* everything for copy and rename — that is the tree's
    # vocabulary, not the render's.
    if (references and self.test_item.isSelected()
            and all(item is self.test_item
                    for item in self.tree.selectedItems())):
        self._clear_views()
        self._show_views()
        self._offer_acts()
        n = len(self.objects)
        self._show_status(
            f'{self.test_item.text(0)}: {n} object{"s" * (n != 1)} — '
            'Generate Report is on the bar; select an object to see it')
        return
    if not references:
        self._clear_views()
        self._offer_acts()
        if self.tree.currentItem() is self.test_item:
            self._show_status(
                f'{self.test_item.text(0)} is empty — import a file, '
                'or drag files onto the tree')
        else:
            self._show_status(
                'Import a file to get started — or drag files onto the '
                'project tree')
        return

    geometries, series, channels, shapes = {}, [], [], []
    reports, picture_sets, matches, sine_specs = [], [], [], []
    sine_levels = []
    for kind, name, obj, detail in references:
        if isinstance(obj, Geometry):
            entry = geometries.setdefault(
                name, {'object': obj, 'components': set(), 'entities': {}})
            if kind == 'component' and detail:
                entry['components'].add(detail)
            elif kind in ENTITY_COMPONENT:
                entry['entities'].setdefault(
                    ENTITY_COMPONENT[kind], []).append(detail)
        elif isinstance(obj, DataArray):
            # one entry per object, not per record: ten cells picked in
            # a grid are one time history restricted to ten records,
            # and the animator refuses a selection of ten objects
            if kind == 'record':
                for entry_name, _obj, records in series:
                    if entry_name == name and records is not None:
                        records.append(detail)
                        break
                else:
                    series.append((name, obj, [detail]))
            else:
                series.append((name, obj, None))
        elif isinstance(obj, ShapeSet):
            shapes.append((name, obj, detail if kind == 'mode' else None))
        elif isinstance(obj, ChannelTable):
            channels.append((name, obj, detail if kind == 'channel' else None))
        elif isinstance(obj, Report):
            reports.append((name, obj))
        elif isinstance(obj, MatchedModes):
            matches.append((name, obj))
        elif isinstance(obj, (SineSweepSpecification, SineLevelSet)):
            # picked tones restrict either object, exactly as
            # picked records restrict a data object
            into = (sine_specs
                    if isinstance(obj, SineSweepSpecification)
                    else sine_levels)
            if kind == 'tone':
                for entry_name, _obj, picks in into:
                    if entry_name == name and picks is not None:
                        picks.append(detail)
                        break
                else:
                    into.append((name, obj, [detail]))
            else:
                into.append((name, obj, None))
        elif isinstance(obj, Photos):
            # picked photos restrict the object, exactly as records do
            if kind == 'photo':
                for entry_name, _obj, picks in picture_sets:
                    if entry_name == name and picks is not None:
                        picks.append(detail)
                        break
                else:
                    picture_sets.append((name, obj, [detail]))
            else:
                picture_sets.append((name, obj, None))

    self._update_dof_controls(geometries, series)
    self._update_link_actions()
    shape_table = bool(shapes) and not geometries and not series
    shape_sets = list(dict.fromkeys(
        (name, id(obj)) for name, obj, _detail in shapes))
    comparing = (len(shape_sets) == 2 and len(geometries) <= 1
                 and not series and not any(
                     entry['components'] or entry['entities']
                     for entry in geometries.values()))
    self._set_compare_layout(comparing)
    # a comparison if there is one to make, otherwise what the
    # specification says on its own — either way a table per channel
    # that the plot follows
    # what a specification says on its own, a row per channel. A
    # specification *with* a measurement has no table: the bar
    # charts carry those numbers, and carry them better.
    specifications = (self._specification_rows(series)
                      if series and not channels else None)
    # the levels are a reading asked for, not the default: the
    # spectra alone by default, and the RMS toggle brings the bars
    # up with the table beneath (Brandon, 2026-09-06)
    levels = (specifications
              if specifications is not None and self.data_pane.rms_wanted
              else None)
    # a transient record beside its target splits the space: the
    # waveform on top, and under it every control channel with how
    # far it is from what was asked. The plot draws one channel or a
    # few, so the table is where the rest are still accounted for
    replicating = bool(series) and self._replication_found(series)
    authoring = self._authoring(shapes, geometries, series, channels)
    # no 3-D view in edit mode (Brandon, 2026-09-06): every editing
    # gesture is on the flat plot, so the stage stands down while
    # the sheet is open and comes back when it closes
    self.data_pane.flat_only = authoring is not None
    self._show_views(three_d=bool(geometries),
                     plots=bool(series) or bool(picture_sets)
                     or bool(sine_specs) or bool(sine_levels)
                     or authoring is not None,
                     table=bool(channels) or shape_table or comparing
                     or bool(reports) or bool(matches)
                     or levels is not None
                     or bool(replicating)
                     or bool(series and self._srs_found(series))
                     or (bool(series)
                         and self._compliance_rows(series) is not None))
    if not (geometries or series or channels or picture_sets
            or matches or sine_specs or sine_levels):
        self._clear_views()


    # the rigid-body reading is a reading of one whole geometry with
    # nothing riding it: no shapes or data to animate, no part picked
    lone = (len(geometries) == 1 and not shapes and not series
            and not channels and self.editing is None
            and not any(entry['components'] or entry['entities']
                        for entry in geometries.values()))
    self.scene.offer_rigid(lone)
    deflection = self._deflection_for(geometries, series, shapes)
    parts = []
    synthesis_note = ''
    if deflection is not None:
        parts.append(deflection)
        if series:
            parts.append(self._render_series(series, cursor=True))
    else:
        if geometries:
            parts.append(self._render_geometries(list(geometries.items())))
        if sine_specs or sine_levels:
            parts.append(self._render_sine(
                sine_specs[0] if sine_specs else None,
                sine_levels, series))
        elif series:
            overlay, synthesis_note = self._synthesis_series(shapes,
                                                             series)
            parts.append(self._render_series(series + overlay))
            if synthesis_note:
                parts.append(synthesis_note)
    if picture_sets and not series:
        parts.append(self._render_photos(picture_sets))
    if reports:
        parts.append(self._render_report_builder(*reports[0]))
    if channels:
        name, table, _detail = channels[0]
        # picked cells in the tree's grid are the rows shown here;
        # selecting the object (no picks) shows the whole table
        rows = sorted({detail for n, _table, detail in channels
                       if n == name and detail is not None})
        summary = self._render_table(table, rows=rows or None, name=name)
        tables = {n for n, _table, _detail in channels}
        if len(tables) > 1:
            summary += f' (1 of {len(tables)} tables selected)'
        parts.append(summary)
    if levels is not None:
        parts.append(self._render_specifications(levels))
    if matches and not comparing and not channels:
        parts.append(self._render_matches(*matches[0]))
    if shapes and deflection is None and not synthesis_note:
        if shape_table:
            sets = list(dict.fromkeys(
                (name, id(obj)) for name, obj, _detail in shapes))
            if len(sets) == 2:
                # the same interactive pairing as the animated
                # comparison — no geometry required to match modes
                summary = self._compare_flat(shapes)
            else:
                summary = self._render_shape_table(
                    shapes[0][1], mode=shapes[0][2],
                    name=shapes[0][0])
            parts.append(summary)
        else:
            parts.append(self._shape_summary(shapes, geometries))
    if authoring is not None:
        parts.append(self._render_author(authoring))
        if authoring[0] == 'table':
            # the table's bar is put away by its own renderer; the
            # sheet's toggle lives there, so it comes back alone
            self.mode_table_action.setVisible(False)
            self.mac_bars_action.setVisible(False)
            self.table_bar.show()
    elif self.author_action.isVisible() and channels and not shapes:
        self.mode_table_action.setVisible(False)
        self.mac_bars_action.setVisible(False)
        self.table_bar.show()
    self._offer_acts()
    self._show_status('  |  '.join(part for part in parts if part))
add_matches
add_matches() -> None

Commit the selected MAC squares to the matched modes for this pair of sets — the project creates or extends the object, links it to the Basis group, and keeps the MAC values that were displayed (a name-matched recompute would not reproduce a projected comparison).

Source code in src/visualdynamics/gui/main_window.py
def add_matches(self) -> None:
    """Commit the selected MAC squares to the matched modes for
    this pair of sets — the project creates or extends the object,
    links it to the Basis group, and keeps the MAC values that were
    displayed (a name-matched recompute would not reproduce a
    projected comparison)."""
    if self._compare is None or not self._compare.get('pairs'):
        return
    a_name, b_name = self._compare['pair']
    matrix = self._compare['matrix']
    pairs = [list(pair) for pair in self._compare['pairs']]
    macs = [float(matrix[r, c]) for r, c in self._compare['pairs']]
    found = self._matched_for(a_name, b_name)
    if found is None:
        selected = self.tree.selectedItems()
        current = self.tree.currentItem()
        name = self.project.match_modes(a_name, b_name, pairs=pairs,
                                        macs=macs)
        self.show_object(name, select=False)
        # the comparison is the user's context: put it back
        if current is not None:
            self.tree.setCurrentItem(current)
        self.tree.clearSelection()
        for item in selected:
            item.setSelected(True)
        matched = self.objects[name]
    else:
        name, matched = found
        matched.add(pairs, macs)
        # growing an existing set is an act like creating one: the
        # first commit journals through the match_modes verb, and
        # this is the same statement made again (Brandon,
        # 2026-08-30, the audit's second pass)
        self.project.record_call(
            matched, 'add',
            [(int(a), int(b)) for a, b in pairs],
            [float(m) for m in macs])
        a_home = self.project.geometry_for(a_name)
        b_home = self.project.geometry_for(b_name)
        was = (matched.first_geometry, matched.second_geometry)
        matched.first_geometry = (matched.first_geometry
                                  or (a_home[0] if a_home else None))
        matched.second_geometry = (matched.second_geometry
                                   or (b_home[0] if b_home else None))
        if (matched.first_geometry, matched.second_geometry) != was:
            self.project.record_setting(
                matched, 'first_geometry', matched.first_geometry)
            self.project.record_setting(
                matched, 'second_geometry', matched.second_geometry)
        self._refresh_item(self._item_for_object(name), matched)
    self.render_current()
    self._show_status(
        f'{name}: {len(pairs)} '
        f'match{"es" * (len(pairs) != 1)} added '
        f'({matched.num_matches} total)')
compute_spectra
compute_spectra() -> None

Averaged spectra from the selected time history, one record per channel, added to the project beside it.

Source code in src/visualdynamics/gui/main_window.py
def compute_spectra(self) -> None:
    """Averaged spectra from the selected time history, one record
    per channel, added to the project beside it."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to compute spectra',
        self.project.compute_spectra)
    if acted is None:
        return
    name, obj, added = acted
    spectra = self.objects[added]
    frames = obj.num_records // max(spectra.num_records, 1)
    self.show_object(added)
    self._show_status(
        f'{added}: {spectra.num_records} channel '
        f'spectr{"a" if spectra.num_records != 1 else "um"} averaged '
        f'over {frames} frame{"s" * (frames != 1)} — linked to '
        f'{name}')
project_onto_basis
project_onto_basis() -> None

The other set sampled at the Basis set's DOFs, added as a new object — the project does the projection and the linking.

Source code in src/visualdynamics/gui/main_window.py
def project_onto_basis(self) -> None:
    """The other set sampled at the Basis set's DOFs, added as a
    new object — the project does the projection and the linking."""
    candidates = self._correlation_candidates()
    if candidates is None:
        return
    basis_name = candidates['basis'][0]
    other_name = candidates['other'][0]
    percent, ok = QInputDialog.getDouble(
        self, 'Project onto Basis DOFs',
        'Node match tolerance (% of the basis model size):',
        2.0, 0.01, 100.0, 2)
    if not ok:
        return
    try:
        added = self.project.project_onto_basis(
            other_name, onto=basis_name, tolerance=percent / 100.0)
    except ValueError as refusal:
        self._show_status(f'{other_name}: {refusal}')
        return
    self.show_object(added)
    report = self.objects[added].projection_report
    home = self.project.geometry_for(basis_name)
    if home is not None and home[1].units_defined:
        worst = self.unit_system.from_si(report['worst'], 'length')
        unit = self.unit_system.label_text('length')
    else:
        worst, unit = report['worst'], 'model units'
    note = (f'; {len(report["dropped"])} basis DOFs dropped'
            if report['dropped'] else '')
    self._show_status(
        f'{added}: {report["matched"]} of {report["total"]} basis '
        f'nodes matched (worst {worst:.4g} {unit}){note} — select '
        f'it beside {basis_name} to compare')
transform_selection
transform_selection() -> None

The selected record through the selected shape set — modal responses from physical, or physical from modal — added to the project by the verb a script calls. No pane and no preview: a transform has no settings (Brandon, 2026-09-04), so it is an act like Integrate, and the account it leaves is on the status line and on the object's transform_report.

Source code in src/visualdynamics/gui/main_window.py
def transform_selection(self) -> None:
    """The selected record through the selected shape set — modal
    responses from physical, or physical from modal — added to the
    project by the verb a script calls. No pane and no preview: a
    transform has no settings (Brandon, 2026-09-04), so it is an
    act like Integrate, and the account it leaves is on the status
    line and on the object's `transform_report`."""
    candidates = self._transform_candidates()
    if candidates is None:
        self._show_status('Select a data object and a shape set '
                          'together to transform one through the '
                          'other')
        return
    history, shapes, direction, records = candidates
    # the pick only when there is one, so the journal's line is the
    # one a script would write for the whole object
    picked = {} if records is None else {'records': records}
    try:
        if direction == 'physical':
            added = self.project.transform(history, shapes, **picked)
        else:
            added = self.project.expand(history, shapes, **picked)
    except ValueError as refusal:
        self._show_status(f'{history}: {refusal}')
        return
    report = self.objects[added].transform_report
    self.show_object(added)
    self._show_status(f'{added}: {report.describe()}, through {shapes}')
generate_typed_report
generate_typed_report() -> None

Generate the report the project's declared type calls for.

Source code in src/visualdynamics/gui/main_window.py
def generate_typed_report(self) -> None:
    """Generate the report the project's declared type calls for."""
    self.generate_report(PROJECT_TEMPLATES[self.project_type])
extract_sine_levels
extract_sine_levels(time_name=None) -> None

Each specification tone's level, read out of the recording and added beside it — the project verb, with the tree kept in step and the result selected so the comparison is one click.

Source code in src/visualdynamics/gui/main_window.py
def extract_sine_levels(self, time_name=None) -> None:
    """Each specification tone's level, read out of the recording
    and added beside it — the project verb, with the tree kept in
    step and the result selected so the comparison is one click."""
    if time_name is None:
        obj = self.current_object()
        if not isinstance(obj, TimeHistory):
            self._show_status(
                'Select a time history to extract sine levels')
            return
        time_name = self.object_item().text(0)
    try:
        added = self.project.extract_sine(time_name)
    except (ValueError, AttributeError) as refusal:
        self._show_status(f'{time_name}: {refusal}')
        return
    for k, name in enumerate(added):
        self.show_object(name, source=time_name, select=k == 0)
    self._show_status(
        f'{len(added)} tone level{"s" * (len(added) != 1)} '
        f'extracted from {time_name}')
compute_psds
compute_psds() -> None

Averaged auto-power spectral densities from the selected time history, one record per channel, added beside it.

Source code in src/visualdynamics/gui/main_window.py
def compute_psds(self) -> None:
    """Averaged auto-power spectral densities from the selected
    time history, one record per channel, added beside it."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to compute PSDs',
        self.project.compute_psds)
    if acted is None:
        return
    name, obj, added = acted
    psds = self.objects[added]
    frames, where = self._averaged_frames(obj, psds.num_records)
    self.show_object(added)
    self._show_status(
        f'{added}: {psds.num_records} channel '
        f'PSD{"s" * (psds.num_records != 1)} averaged over '
        f'{frames} frame{"s" * (frames != 1)}{where} — linked to {name}')
compute_octave
compute_octave() -> None

A spectrum on proportional bands, added beside it — the Octave Bands panel's own act, making exactly the steps its preview draws with the spacing set beside them.

Source code in src/visualdynamics/gui/main_window.py
def compute_octave(self) -> None:
    """A spectrum on proportional bands, added beside it — the
    Octave Bands panel's own act, making exactly the steps its
    preview draws with the spacing set beside them."""
    # the spacing is the panel's — the very steps being previewed
    # are what the button makes, which is the whole contract
    per_octave = self.data_pane.octave_panel.per_octave()
    acted = self._act_on(
        lambda o: isinstance(o, Psd) and not isinstance(o, Specification),
        'Select a PSD or CPSD to band',
        self.project.compute_octave, per_octave)
    if acted is None:
        return
    name, obj, added = acted
    banded = self.objects[added]
    self.show_object(added)
    self._show_status(
        f'{added}: {len(banded.abscissa)} bands from '
        f'{len(obj.abscissa)} lines, {banded.num_records} '
        f'record{"s" * (banded.num_records != 1)} — linked to {name}')
compute_frfs
compute_frfs() -> None

FRFs from the selected time history, added beside it.

Asks which estimator first. The three differ in where they assume the noise is — on the response, on the reference, or on both — and that is not something the data can say; it is a statement about the instrumentation, which the person who ran the test is the one holding.

The drives are the history's own excitation channels and the frames are the ones a PSD would use, so the FRFs, the PSDs and the multiple coherence beside them all describe one measurement.

Source code in src/visualdynamics/gui/main_window.py
def compute_frfs(self) -> None:
    """FRFs from the selected time history, added beside it.

    Asks which estimator first. The three differ in where they
    assume the noise is — on the response, on the reference, or on
    both — and that is not something the data can say; it is a
    statement about the instrumentation, which the person who ran
    the test is the one holding.

    The drives are the history's own excitation channels and the
    frames are the ones a PSD would use, so the FRFs, the PSDs and
    the multiple coherence beside them all describe one measurement.
    """
    obj = self.current_object()
    if not isinstance(obj, TimeHistory):
        self._show_status('Select a time history to compute FRFs')
        return
    name = self.object_item().text(0)
    methods = list(TimeHistory.FRF_METHODS)
    labels = [f'{method}{note}' for method, note in zip(methods, (
        'noise on both (total least squares)',
        'noise on the response',
        'noise on the reference (single reference only)'))]
    chosen, ok = QInputDialog.getItem(
        self, 'Compute FRFs', 'Estimator:', labels, 0, False)
    if not ok:
        return
    method = methods[labels.index(chosen)]
    seeded = obj.averaging is None
    try:
        added = self.project.compute_frfs(name, method)
    except ValueError as refusal:
        self._show_status(f'{name}: {refusal}')
        return
    frfs = self.objects[added]
    drives = len(set(frfs.reference_dof))
    self.show_object(added)
    self._show_status(
        f'{added}: {frfs.num_records} {method} FRFs, '
        f'{frfs.num_records // max(drives, 1)} responses against '
        f'{drives} reference{"s" * (drives != 1)}, '
        f'{obj.averaging.frames * max(obj.records_per_channel.values())} '
        f'averages{" (detected)" if seeded else ""} — linked to {name}')
compute_multiple_coherence
compute_multiple_coherence() -> None

Multiple coherence from the selected time history.

Over the same frames a PSD would be averaged from, so the two describe one measurement — the averaging view sets both.

With no averaging set at all, Project.compute_multiple_coherence works one out and stores it on the history, the way the shock calculator stores the events it detects. Storing it is the point: a coherence that quietly averaged differently from the PSD beside it would describe a different measurement, and the whole reason it follows the averaging is so that it does not. This says which of the two happened.

Source code in src/visualdynamics/gui/main_window.py
def compute_multiple_coherence(self) -> None:
    """Multiple coherence from the selected time history.

    Over the same frames a PSD would be averaged from, so the two
    describe one measurement — the averaging view sets both.

    With no averaging set at all, `Project.compute_multiple_coherence`
    works one out and *stores it on the history*, the way the shock
    calculator stores the events it detects. Storing it is the
    point: a coherence that quietly averaged differently from the
    PSD beside it would describe a different measurement, and the
    whole reason it follows the averaging is so that it does not.
    This says which of the two happened.
    """
    obj = self.current_object()
    if not isinstance(obj, TimeHistory):
        self._show_status(
            'Select a time history to compute multiple coherence')
        return
    name = self.object_item().text(0)
    seeded = obj.averaging is None
    try:
        added = self.project.compute_multiple_coherence(name)
    except ValueError as refusal:
        self._show_status(f'{name}: {refusal}')
        return
    coherence = self.objects[added]
    drives = len(obj.drive_dofs())
    self.show_object(added)
    self._show_status(
        f'{added}: {coherence.num_records} '
        f'response{"s" * (coherence.num_records != 1)} against '
        f'{drives} reference{"s" * (drives != 1)}, '
        f'{obj.averaging.frames} averages'
        f'{" (detected)" if seeded else ""} — linked to {name}')
compute_srs
compute_srs() -> None

Shock response spectra from the selected time history — one curve per channel per shock, added beside it.

The shocks come from the history itself, the way averaging does: whatever the shock view has on it at the time. With none, Project.compute_srs runs the detector first, so the calculator answers rather than asking the user to go and find the events by hand.

Source code in src/visualdynamics/gui/main_window.py
def compute_srs(self) -> None:
    """Shock response spectra from the selected time history — one
    curve per channel per shock, added beside it.

    The shocks come from the history itself, the way averaging does:
    whatever the shock view has on it at the time. With none,
    `Project.compute_srs` runs the detector first, so the calculator
    answers rather than asking the user to go and find the events by
    hand.
    """
    settings = self.data_pane.shock_panel.srs_settings()
    acted = self._act_on(
        TimeHistory,
        'Select a time history to compute an SRS',
        self.project.compute_srs, **settings)
    if acted is None:
        return
    name, obj, added = acted
    spectra = self.objects[added]
    # what the windows turned out to be, said in the terms they came
    # from: detected events, the frames the record is read as, or the
    # single playing a target is. `shocks` is None where nothing was
    # detected, which is now the ordinary case rather than an error
    events = len(obj.shocks or ())
    if events:
        over = f'{events} shock{"s" * (events != 1)}'
    elif obj.averaging is not None:
        over = (f'{obj.averaging.frames} '
                f'frame{"s" * (obj.averaging.frames != 1)}')
    else:
        over = 'the whole record'
    self.show_object(added)
    self._show_status(
        f'{added}: {spectra.num_records} spectra over {over} '
        f'at Q={spectra.q:g} — linked to {name}')
filter_data
filter_data() -> None

The selected time history through its filter, every channel, added beside it. The settings are the history's own; with none set, the suggestion is adopted — same contract as Project.filter_data.

Source code in src/visualdynamics/gui/main_window.py
def filter_data(self) -> None:
    """The selected time history through its filter, every
    channel, added beside it. The settings are the history's own;
    with none set, the suggestion is adopted — same contract as
    `Project.filter_data`."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to filter',
        self.project.filter_data)
    if acted is None:
        return
    name, obj, added = acted
    filtering = obj.filtering
    self.show_object(added)
    self._show_status(
        f'{added}: {filtering.describe()}, order '
        f'{filtering.order}, zero phase — linked to {name}')
truncate_data
truncate_data() -> None

The selected time history cut to its span, every channel, added beside it. The span is the history's own; with none set, this refuses and says where to set one — keeping the whole record is not an act, so there is no suggestion to adopt the way Filter Data adopts one.

Source code in src/visualdynamics/gui/main_window.py
def truncate_data(self) -> None:
    """The selected time history cut to its span, every channel,
    added beside it. The span is the history's own; with none
    set, this refuses and says where to set one — keeping the
    whole record is not an act, so there is no suggestion to
    adopt the way Filter Data adopts one."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to truncate',
        self.project.truncate_data)
    if acted is None:
        return
    name, obj, added = acted
    truncation = obj.truncation
    cut = self.objects[added]
    self.show_object(added)
    self._show_status(
        f'{added}: {truncation.describe()} — '
        f'{cut.ordinate.shape[-1]} of {obj.ordinate.shape[-1]} '
        f'samples, linked to {name}')
generate_rigid_body_modes
generate_rigid_body_modes() -> None

The six rigid-body mode shapes of the selected geometry, about the point the rigid-body pane shows, added to its group — the same verb a script calls, so provenance and the badge follow.

Source code in src/visualdynamics/gui/main_window.py
def generate_rigid_body_modes(self) -> None:
    """The six rigid-body mode shapes of the selected geometry,
    about the point the rigid-body pane shows, added to its group
    — the same verb a script calls, so provenance and the badge
    follow."""
    acted = self._act_on(
        Geometry,
        'Select a geometry to make rigid body ' 'modes of',
        self.project.generate_rigid_body_modes)
    if acted is None:
        return
    name, obj, added = acted
    properties = obj.mass_properties
    self.show_object(added)
    self._show_status(
        f'{added}: 6 modes {self._describe_rigid(obj, properties)}, '
        f'linked to {name}')
integrate_history
integrate_history() -> None

One integration of the selected time history — acceleration to velocity, velocity to displacement — added beside it.

Source code in src/visualdynamics/gui/main_window.py
def integrate_history(self) -> None:
    """One integration of the selected time history — acceleration
    to velocity, velocity to displacement — added beside it."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to integrate',
        self.project.integrate)
    if acted is None:
        return
    name, obj, added = acted
    result = self.objects[added]
    left_out = obj.num_records - result.num_records
    aside = (f' — {left_out} record{"s" * (left_out != 1)} of other '
             'quantities left out' if left_out else '')
    self._show_status(
        f'{added}: {result.num_records} channel'
        f'{"s" * (result.num_records != 1)} integrated, drift '
        f'high-passed{aside} — linked to {name}')
    self.show_object(added)
differentiate_history
differentiate_history() -> None

One differentiation of the selected time history — displacement to velocity, velocity to acceleration — added beside it.

Source code in src/visualdynamics/gui/main_window.py
def differentiate_history(self) -> None:
    """One differentiation of the selected time history —
    displacement to velocity, velocity to acceleration — added
    beside it."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to differentiate',
        self.project.differentiate)
    if acted is None:
        return
    name, _obj, added = acted
    result = self.objects[added]
    self._show_status(
        f'{added}: {result.num_records} channel'
        f'{"s" * (result.num_records != 1)} differentiated — '
        f'linked to {name}')
    self.show_object(added)
compute_cpsds
compute_cpsds() -> None

The full cross-spectral matrix from the selected time history — every channel against every channel — added beside it.

Source code in src/visualdynamics/gui/main_window.py
def compute_cpsds(self) -> None:
    """The full cross-spectral matrix from the selected time
    history — every channel against every channel — added beside
    it."""
    acted = self._act_on(
        TimeHistory,
        'Select a time history to compute CPSDs',
        self.project.compute_cpsds)
    if acted is None:
        return
    name, obj, added = acted
    cpsds = self.objects[added]
    channels = round(cpsds.num_records ** 0.5)
    frames, where = self._averaged_frames(obj, channels)
    # shown first: selecting it renders, and the render restates the
    # status line over anything said before it
    self.show_object(added)
    self._show_status(
        f'{added}: {channels}x{channels} cross-spectral matrix '
        f'averaged over {frames} frame{"s" * (frames != 1)}{where} — '
        f'linked to {name}')
generate_report
generate_report(template: str = 'empty') -> str

A new Report object from a starter template, opened to edit.

Templates bind symbolically — '@basis:Frf' and friends, resolved against the link groups at render time — so the report depends on the project's structure, never on what anyone named their objects.

Source code in src/visualdynamics/gui/main_window.py
def generate_report(self, template: str = 'empty') -> str:
    """A new Report object from a starter template, opened to edit.

    Templates bind symbolically — '@basis:Frf' and friends,
    resolved against the link groups at render time — so the
    report depends on the project's structure, never on what
    anyone named their objects.
    """
    name = self.show_object(self.project.generate_report(
        template, name=display_name('Report')))
    self._show_status(
        f'{name}: click a block in the page, edit it in the pane '
        'beside; Insert, Move, Delete and Export are on the bar')
    return name
export_report_template
export_report_template() -> None

Write the selected report on its own, as a template another project can load — into the templates folder by default, which is where Generate Report looks.

Source code in src/visualdynamics/gui/main_window.py
def export_report_template(self) -> None:
    """Write the selected report on its own, as a template another
    project can load — into the templates folder by default, which
    is where Generate Report looks."""
    import pathlib

    from ..io.report_template import SUFFIX, templates_folder

    obj = self.current_object()
    if not isinstance(obj, Report):
        self._show_status('Select a report to save as a template')
        return
    name = self.object_item().text(0)
    folder = templates_folder()
    folder.mkdir(parents=True, exist_ok=True)
    path, _ = QFileDialog.getSaveFileName(
        self, 'Save Report Template', str(folder / f'{name}{SUFFIX}'),
        f'Report template (*{SUFFIX})')
    if not path:
        return
    if not path.endswith(SUFFIX):
        path += SUFFIX
    self.project.export(name, path)
    where = (' — Generate Report offers it'
             if pathlib.Path(path).parent == folder else '')
    self._show_status(f'Saved template {os.path.basename(path)}{where}')
export_report
export_report() -> None

Write the selected report as one self-contained HTML file.

Source code in src/visualdynamics/gui/main_window.py
def export_report(self) -> None:
    """Write the selected report as one self-contained HTML file."""
    from ..report import render_html

    obj = self.current_object()
    if not isinstance(obj, Report):
        self._show_status('Select a report to export')
        return
    name = self.object_item().text(0)
    path, _ = QFileDialog.getSaveFileName(
        self, 'Export Report', f'{name}.html', 'Report (*.html)')
    if not path:
        return
    if not path.endswith('.html'):
        path += '.html'
    skipped = len(obj.unbound(self.objects))
    with open(path, 'w', encoding='utf-8') as out:
        out.write(render_html(obj, self.objects, self.unit_system,
                              links=self.links))
    note = (f' ({skipped} unbound block'
            f'{"s" * (skipped != 1)} left out)') if skipped else ''
    self._show_status(
        f'Exported {os.path.basename(path)}{note} — opens in any '
        'browser, nothing to install')
merge_selected
merge_selected() -> None

Replace the selected objects with their combination.

Source code in src/visualdynamics/gui/main_window.py
def merge_selected(self) -> None:
    """Replace the selected objects with their combination."""
    chosen = self._merge_candidates()
    if chosen is None:
        return
    names = [name for name, _obj in chosen]
    self.tree.blockSignals(True)
    try:
        for name in names:
            self._forget_object_row(name)
        merged_name = self.show_object(self.project.merge(
            *names, name=display_name(
                type(self.objects[names[0]]).__name__)))
    finally:
        self.tree.blockSignals(False)
    self.refresh_compatibility()
    self.render_current()
    what = ', '.join(name for name, _obj in chosen)
    self._show_status(f'Merged {what} into {merged_name}')
start_modal_fit
start_modal_fit() -> None

Fit real normal modes to the selected FRFs, one mode at a time.

The measured CMIF stays solid while the fit's synthesis climbs onto it dashed. A cursor sits on the largest residual peak, and the frequencies on screen are what narrow the search — zoom to say where to look, and the cursor rides the zoom rather than being left off-screen; the table beside the plot lists the fitted modes plus the pending one; Find Mode previews a fit at the cursor, Confirm Mode adopts it and moves to the next residual peak — the natural next mode, since a confirmed peak collapses. A co-selected shape set seeds the session and receives every change in place.

Source code in src/visualdynamics/gui/main_window.py
def start_modal_fit(self) -> None:
    """Fit real normal modes to the selected FRFs, one mode at a time.

    The measured CMIF stays solid while the fit's synthesis climbs
    onto it dashed. A cursor sits on the largest residual peak, and
    the frequencies on screen are what narrow the search — zoom to
    say where to look, and the cursor rides the zoom rather than
    being left off-screen; the table beside the plot lists the
    fitted modes plus the pending one; Find Mode previews a fit at the
    cursor, Confirm Mode adopts it and moves to the next residual
    peak — the natural next mode, since a confirmed peak collapses.
    A co-selected shape set seeds the session and receives every
    change in place.
    """
    # a fresh session, and the previous one's drag budget is no part
    # of it
    self._fit_dear_cost = 0.0
    self._fit_synthesis_stale = True
    # the fitting screen is the flat plot — the CMIF, the cursor and
    # the corner range fields all live on it. Said here as well as
    # in render_current because the toolbar's Fit button reaches
    # this directly, and an FRF showing as a waterfall would
    # otherwise host the whole fit on a hidden surface
    self.data_pane.show_waterfall(False)
    selected = self.selected_references()
    frfs = {name: obj for _kind, name, obj, _detail in selected
                if isinstance(obj, Frf)}
    shape_sets = {name: obj for _kind, name, obj, _detail
                      in selected if isinstance(obj, ShapeSet)}
    if len(frfs) != 1:
        self._show_status('Select one FRF object to fit modes to')
        return
    name, obj = next(iter(frfs.items()))
    records = sorted({d for k, n, _o, d in selected
                      if n == name and k == 'record'}) or None
    self.stop_editing()
    self.close_units_panel()
    self.fit = ModalFitSession(obj, records)
    # the measurement's own statement of which channel to believe
    # where: with a coherence in the project covering these
    # responses, Refine All weights its solve and its judgement by
    # it — a channel the coherence distrusts cannot vote noise into
    # every mode's residues
    self._fit_coherence_name = None
    coherence = self._fit_coherence(name, self.fit.responses)
    if coherence is not None:
        self.fit.weights = self.fit._coherence_weights(
            self.objects[coherence])
        if self.fit.weights is not None:
            self._fit_coherence_name = coherence
    self.fit_name = name
    self.fit_object_name = None
    if len(shape_sets) == 1:
        # a co-selected shape set is the fit to edit: its modes seed
        # the session, and every change publishes back to it in place
        seed_name, seed = next(iter(shape_sets.items()))
        self.fit.adopt(seed)
        self.fit_object_name = seed_name
    # stacked: the CMIF wants the full width up top, with the mode
    # table bottom-left and the MAC bottom-right under it — side by
    # side it was tall and skinny
    self.views.setOrientation(Qt.Orientation.Vertical)
    self._show_views(plots=True, table=True)
    self.fit_bar.show()
    # the plot bar over the CMIF offers the one overlay the fit
    # has: the residual. Show/hide toggles live there, with their
    # kin, rather than among the fit's verbs.
    self.data_pane.show_controls(
        map_wanted=None, diagonal=None, cmif=False,
        complex_data=False, pair=False, residual=True)
    self._fit_model = modal_fit_table_model(self.fit, self)
    self._set_table_model(self._fit_model)
    self._fit_model.dataChanged.connect(self._fit_edited)
    # a fitted mode can be taken back: select its row, hit Delete
    self.table.rows_deletable = True
    self._rows_wired = True
    self.table.rows_deleted.connect(self._delete_fit_modes)
    self._render_fit()
refine_all_modes
refine_all_modes() -> None

Re-fit every confirmed mode's residues together, poles held — see ModalFitSession.refine_residues for what this repairs.

Source code in src/visualdynamics/gui/main_window.py
def refine_all_modes(self) -> None:
    """Re-fit every confirmed mode's residues together, poles held —
    see ModalFitSession.refine_residues for what this repairs."""
    if self.fit is None or len(self.fit.modes) < 2:
        return
    before = [mode['shape'].copy() for mode in self.fit.modes]
    count = self.fit.refine_residues()
    changed = any(not np.array_equal(early, mode['shape'])
                  for early, mode in zip(before, self.fit.modes))
    self._publish_fit()
    self._render_fit()
    # the CMIF may have chosen the sequential answer — see
    # refine_residues — and saying "re-fit" about an unchanged fit
    # would send the eye hunting for a difference that is not there
    self._show_status(
        f'Re-fit {count} modes\' shapes together — frequencies and '
        'dampings kept' if changed else
        'Refine changed nothing — the fits are already as '
        'consistent as the measured CMIF supports')
find_next_mode
find_next_mode() -> None

Put the cursor on the next mode worth fitting.

The largest CMIF peak left in the residual, inside what the plot is showing — which is what Confirm already moves to once a mode is taken. This is that on its own, for hunting before committing to anything.

Source code in src/visualdynamics/gui/main_window.py
def find_next_mode(self) -> None:
    """Put the cursor on the next mode worth fitting.

    The largest CMIF peak left in the residual, inside what the plot
    is showing — which is what Confirm already moves to once a mode
    is taken. This is that on its own, for hunting before committing
    to anything.
    """
    if self.fit is None:
        return
    # A settle left armed by the gesture before this one would fire
    # a quarter-second from now and put the resting message back
    # over the one below — and the gesture before this one is
    # almost always the zoom that said where to look, since the
    # zoom carries the cursor. Cancelled rather than raced: this
    # has already done the settle's work by rendering, and Find
    # Mode is a look rather than a drag catching up.
    self._fit_settle.stop()
    window = self._fit_window()
    low, high = self.fit.searched(window)
    frequency, damping = self.fit.suggest(window)
    self._fit_updating = True
    try:
        row = len(self.fit.modes)
        self._fit_model.dataChanged.emit(
            self._fit_model.index(row, 1), self._fit_model.index(row, 2))
    finally:
        self._fit_updating = False
    self._render_fit()
    self._show_status(
        f'Largest residual peak at {frequency:.2f} Hz, '
        f'{damping * 100:.2f}% — Confirm Mode to adopt it '
        f'(searched {low:.4g} to {high:.4g} Hz)')
fit_pending_mode
fit_pending_mode() -> None

Fit at the cursor without confirming, and restate everything.

There is no button for this any more. Dragging fits as it goes and the MAC follows a moment after the cursor stops, so by the time anyone reached for Fit Mode the work was already done — the button was asking for something it had.

It stays as a method because it is the whole view restated at once, which a caller that changed something out of band still wants: a damping typed into the table, or a script driving the fit with no cursor to drag.

Source code in src/visualdynamics/gui/main_window.py
def fit_pending_mode(self) -> None:
    """Fit at the cursor without confirming, and restate everything.

    There is no button for this any more. Dragging fits as it goes
    and the MAC follows a moment after the cursor stops, so by the
    time anyone reached for Fit Mode the work was already done —
    the button was asking for something it had.

    It stays as a method because it is the whole view restated at
    once, which a caller that changed something out of band still
    wants: a damping typed into the table, or a script driving the
    fit with no cursor to drag.
    """
    if self.fit is None:
        return
    self.fit.fit_pending()
    self._fit_updating = True
    try:
        row = len(self.fit.modes)
        self._fit_model.dataChanged.emit(
            self._fit_model.index(row, 1), self._fit_model.index(row, 2))
    finally:
        self._fit_updating = False
    self._render_fit()

Functions: