Architecture of the Qt 6 Widgets editor: model/view separation, signal flow, item models, the undo bridge, and the viewport. Layer rules live in the architecture overview.
Source layout under editor/src/:
| Directory | Contents |
|---|---|
document/ |
Document, SelectionModel, SceneTreeModel, DiagnosticsModel, EditorCommand, Project, ProjectFilesModel, LibraryManifest, SceneSidecar, AutosaveManager — all editor state and logic, testable headless |
panels/ |
Scene tree, properties, diagnostics dock widgets — thin |
viewport/ |
ViewportWidget (QOpenGLWidget), camera, CPU picking |
render/ |
Renderer interface, GLRenderer, GL function loading, scene builder |
tools/ |
Editing-tool state machines (Tool, ToolManager) |
app/ |
MainWindow, actions, settings, the Icons helper, main.cpp |
All state and logic live in document/ and in pure modules
(viewport/picking, render/scene_builder). Widgets only arrange and
translate — no kernel calls from widget event handlers except through
Document.
Documentis the editor's model root: it owns theRoadNetwork, its tessellation (NetworkMesh), the parser diagnostics, and theQUndoStack. It is the only mutator of the network. It is QtCore-only, so it tests offscreen without a GUI — including its two persistence paths,load()/save(), which pair the.xodrwith its Layer-2 sidecar (see persistence).SelectionModelis the single source of truth for the current selection. Every selection flow — scene tree, viewport picking, diagnostics navigation — goes through it; widgets never notify each other directly. Views that mirror the selection guard against ping-pong with a re-entrancy bool.SelectionModelhard-clears onDocument::loaded()and validates IDs per call: generational IDs are only stale-safe within oneRoadNetworkinstance. After a reload, an old ID can alias a fresh entity, so lookups alone cannot detect staleness. Copy this pattern for any future ID-holding state.
graph LR
D[Document] -- "loaded / mesh_changed /<br/>topology_changed / diagnostics_changed" --> M[SceneTreeModel<br/>DiagnosticsModel]
D -- signals --> V[ViewportWidget]
M --> P[Panels]
P -- "user picks a row" --> S[SelectionModel]
V -- "user clicks (CPU pick)" --> S
S -- selection_changed --> P
S -- selection_changed --> V
P -- "user edits" --> D
T[Tools] -- "edit::Command via<br/>Document::push_command" --> D
- New-syntax
connect(&obj, &Class::signal, ...)only — compile-time checked. Never the string-basedSIGNAL()/SLOT()macros. No timers polling state. - Parent-child ownership: widgets are
newed with a parent and never manually deleted. QObjects owned by value (theDocumentand models insideMainWindow) are declared before the widget pointers that reference them, so destruction order stays correct.deleteLater()only for objects Qt may still reference in the current event-loop turn.
Every QAbstractItemModel subclass (SceneTreeModel, DiagnosticsModel,
and any future model) follows this checklist:
- Flat snapshot rebuilt inside
beginResetModel()/endResetModel()onDocument::loaded()— reset-based rebuild, not incremental patching. internalIdis an integer node index. Never store pointers in aQModelIndex.- Reverse-lookup maps (e.g.
index_for_road/index_for_lane) soSelectionModelchanges can be mirrored into views. - A
QAbstractItemModelTester(Fatal mode) GoogleTest ships in the same commit as the model — see testing.
The kernel owns editing semantics (edit command layer); the editor bridges them onto Qt's undo framework:
Document::push_command(std::unique_ptr<edit::Command>)is the single entry point for kernel mutations. It applies the command; on success it wraps it in aKernelEditorCommand(aQUndoCommandsubclass, seedocument/editor_command.hpp), pushes it onto theQUndoStack, re-meshes incrementally from the command'sDirtySet, and emitsmesh_changed()(plustopology_changed()when roads/junctions were added or removed). A failed apply changes nothing, is not pushed, and surfaces as a diagnostic.redo()drives the kernel command'sapply,undo()itsrevert. BecauseQUndoStackcallsredo()immediately on push and the command is already applied, the bridge is constructedalready_appliedand skips exactly that firstredo().- The stack clears on every load — commands must never outlive the network they captured snapshots of.
- The kernel's headless
edit::EditStackis for Python and tests only; a document is never driven by both stacks.
Editing tools (tools/) are viewport-agnostic controllers: they receive
abstract events (world-space cursor, picks, modifiers) translated by
ViewportWidget and act on the network exclusively through Document
commands, so their interaction logic runs headless under GoogleTest.
Rendering sits behind the abstract Renderer interface
(render/renderer.hpp — no GL types in the header); GLRenderer is the
OpenGL 3.3 core implementation. GL code exists only in editor/src/render/
and ViewportWidget.
QOpenGLWidget lifecycle rules:
- A 3.3 core profile is requested app-wide via
QSurfaceFormatinmain()beforeQApplicationis constructed (macOS requirement). initializeGLloads GL entry points through an injectedProcResolver(render/gl_functions.hpp— a plain function-pointer resolver, so the loader stays toolkit-agnostic), then callsRenderer::init(). GL resources are destroyed betweenmakeCurrent()/doneCurrent().- Scene uploads are deferred: document signals set a dirty flag and the next
paintGLuploads — never touch GL without a current context.paintGLrenders at widget ×devicePixelRatioFpixels (HiDPI-safe). - Renderer state must stay rebuildable from
Documentat any time, so a lost GL context is recoverable.
Picking is CPU-side and pure (viewport/picking.hpp): ray generation
from camera matrices, per-road AABB prefilter, Möller–Trumbore triangle
intersection over the kernel mesh — no Qt, no GL, fully unit-testable
headless. Do not unit-test paintGL/GLRenderer (offscreen CI has no GL
3.3); test camera math and picking instead.
Default Qt style everywhere: no QML, no stylesheets, no custom theme, no icon
packs. Sobriety is layout discipline, not decoration. Icons are monochrome
line SVGs (stroke currentColor) loaded through the Icons helper
(app/icons.hpp), which tints them to the active palette so one asset serves
light and dark themes, falling back to QIcon::fromTheme.
Deployment (macdeployqt/windeployqt at install time, Linux AppImage) and platform-specific rules are covered in cross-platform. Qt's LGPL constraints — dynamic linking only — are in dependencies.