Files
panopainter/docs/modernization/roadmap.md

40 KiB

PanoPainter Modernization Roadmap

Status: live Last updated: 2026-06-17

This roadmap is now architecture-first. The active execution queue lives in docs/modernization/tasks.md. Completed and superseded task history moved to docs/modernization/tasks-done.md. The debt log remains docs/modernization/debt.md.

Objective

Turn PanoPainter into a thin composition-root application over separable C++23 components while preserving current behavior.

The target end state is not "more planners around the same legacy shell". The next phase is measured by ownership transfer in the live app, not by planner count, CLI breadth, or test count. The target end state is:

  • real component ownership
  • real platform boundaries
  • real renderer boundaries
  • a thin panopainter_app
  • legacy containment targets either deleted or reduced to trivial, debt-tracked adapters

What This Roadmap Covers

  • app architecture
  • component boundaries
  • platform boundaries
  • renderer/app ownership boundaries
  • the order of work needed to finish the split

It does not try to be the full build, test, or automation reference. Those details live in the other modernization docs when needed.

What Does Not Count As Top-Priority Progress

These can still be useful, but they are not first-order modernization work while the app shell still mostly looks like the old codebase:

  • planner-only extraction that leaves the same live owner in place
  • new CLI surface without corresponding live app ownership reduction
  • test-only or automation-only expansion that does not unblock code movement
  • backend lab scaffolds
  • debt-log churn without a target or ownership change

Reality Check

The codebase is meaningfully farther along than the old flat app, but it is not close to the final architecture yet. Historical percentage claims such as the earlier 67% score should not be used as a proxy for architectural completion. The live app still mostly runs through the same large shell and hotspot files.

What is already real:

  • pp_foundation
  • pp_assets
  • pp_paint
  • pp_document
  • pp_renderer_api
  • pp_renderer_gl
  • pp_paint_renderer
  • pp_ui_core
  • pp_platform_api
  • pp_app_core

What is still carrying too much live ownership:

  • pp_panopainter_ui: 34 files, about 9102 lines
  • panopainter_app: 29 files, about 8817 lines
  • pp_legacy_paint_document: 7 files, about 5709 lines
  • pp_legacy_app: 20 files, about 4368 lines
  • pp_legacy_ui_core: 20 files, about 3770 lines

Current hotspot files:

  • src/canvas.cpp: 17 lines
  • src/app_layout.cpp: 109 lines
  • src/canvas_modes.cpp: 1 line
  • src/node.cpp: 12 lines
  • src/main.cpp: 10 lines
  • src/node_panel_brush.cpp: 2 lines
  • src/node_stroke_preview.cpp: 76 lines
  • src/node_canvas.cpp: 69 lines
  • src/app.cpp: 94 lines
  • src/app_dialogs.cpp: 95 lines

Latest slice:

  • The full retained Apple document bridge construction no longer lives inline in src/platform_legacy/legacy_platform_services.cpp; it now lives behind active_legacy_apple_document_platform_services() in src/platform_legacy/legacy_platform_state.*, and that retained service now resets when seeded Apple handles change so first-use bridge capture stays in sync with the active entrypoint state.

  • Win32 main-thread task dispatch no longer reaches AppRuntime through App::I inside src/platform_windows/windows_platform_services.cpp; src/platform_windows/windows_runtime_shell.* now binds the active runtime explicitly and clears that binding on shutdown, leaving the Windows queue helper as a thinner runtime forwarder.

  • App no longer owns and_app or and_engine; the retained Android entrypoint now seeds only the explicit legacy platform storage snapshot needed by touched platform services instead of storing Android-native handles on the app singleton.

  • active_legacy_storage_paths() no longer snapshots App::I lazily inside src/platform_legacy/legacy_platform_state.*; storage roots are now seeded explicitly from app startup plus the iOS, macOS, and Android entrypoints through set_legacy_storage_paths(...).

  • pp_platform_api no longer compiles src/platform_linux/linux_platform_services.*; Linux concrete platform code now lives in pp_platform_linux, which pp_legacy_app and pp_platform_api_tests link where needed.

  • Win32 main-thread queued task ownership now lives in AppRuntime instead of src/platform_windows/windows_platform_services.cpp, which removes another runtime queue from retained platform-local static state and leaves the Windows shell as a thin forwarder.

  • The platform_legacy-mirrored Apple and GLFW handle cluster no longer lives on App; retained Apple/GLFW platform state is now seeded explicitly from the iOS, macOS, Linux, and WebGL entrypoints through src/platform_legacy/legacy_platform_state.*.

  • pp_platform_api no longer compiles src/platform_apple/apple_platform_services.*; Apple concrete platform code now lives in the new pp_platform_apple target, and panopainter_app plus pp_platform_api_tests link that concrete target where needed.

  • Retained GLFW window hooks/state and retained Apple UI/app handle snapshots now live in src/platform_legacy/legacy_platform_state.* instead of staying inline in src/platform_legacy/legacy_platform_services.cpp, which trims another process-global platform-state pocket out of the legacy platform shell and removes more direct App::I reads from touched platform paths.

  • Windows VR session snapshot ownership now lives in src/platform_windows/windows_vr_shell.h and src/platform_windows/windows_platform_services.* instead of on App, with app-side reads now routed through App::vr_session_snapshot().

  • The live Windows entry shell now routes through run_main_application(...) in src/platform_windows/windows_runtime_shell.*, leaving src/main.cpp as a minimal entry wrapper around main(...) and WinMain(...).

  • Retained legacy storage-path state now lives in src/platform_legacy/legacy_platform_state.* instead of staying inline in src/platform_legacy/legacy_platform_services.cpp, which trims another process-global platform-state pocket out of the legacy platform shell.

  • The remaining NodeStrokePreview clone-init, stroke-frame planning, mix-pass adapter wiring, sample-pass adapter wiring, and immediate-draw request construction now route through src/legacy_node_stroke_preview_runtime_services.*, src/legacy_node_stroke_preview_draw_services.*, and src/legacy_node_stroke_preview_sample_services.*, leaving src/node_stroke_preview.cpp as a thinner live adapter.

  • NodeCanvas::handle_event() now routes through handle_legacy_node_canvas_event(...) in src/legacy_canvas_tool_services.*, leaving src/node_canvas.cpp as a much thinner controller shell.

  • App::open_document() now routes through src/legacy_document_open_services.cpp, which moved the document route classification and unsaved-project gating out of src/app.cpp and into the retained document-open helper.

  • App::dialog_layer_rename() now routes through open_legacy_document_layer_rename_dialog(...) in src/legacy_document_layer_services.*, which moved the remaining overlay-open/wire/close workflow out of src/app_dialogs.cpp.

  • The remaining low-level NodeStrokePreview viewport/query and texture-slot plumbing now lives in src/legacy_node_stroke_preview_runtime_services.* instead of staying inline in src/node_stroke_preview.cpp.

  • NodePanelBrushPreset global panel registration now lives in src/legacy_brush_preset_list_services.* instead of staying on the live node type as a static registry field.

  • The remaining generic Node geometry/state pocket for SetSize(...), SetMinSize(...), SetMaxSize(...), and SetPosition(const glm::vec2) now lives in src/legacy_ui_node_style.* instead of staying inline in src/node.cpp.

  • Node::app_redraw() and Node::watch(...) now live in src/legacy_ui_node_execution.cpp instead of staying inline in src/node.cpp.

  • NodeStrokePreview::draw_stroke_immediate() now routes through execute_legacy_node_stroke_preview_immediate_draw(...) in src/legacy_node_stroke_preview_runtime_services.*, which moves render-target validation, viewport/clear-color save-restore, and immediate-runtime request assembly out of the live node file.

  • The remaining NodePanelBrush and NodePanelBrushPreset member bodies now live in the existing retained helper layers (src/legacy_brush_panel_item_ui.*, src/legacy_brush_panel_ui.*, src/legacy_brush_panel_services.*, src/legacy_brush_preset_panel_ui.*, src/legacy_brush_preset_list_services.*, and src/legacy_brush_preset_services.*), leaving src/node_panel_brush.cpp as a thin translation unit.

  • Node::load_internal(...) now routes through load_legacy_ui_node(...) in src/legacy_ui_node_loader.*, which moves the init/attribute-parse/create/child-load/loaded shell out of src/node.cpp.

  • The remaining Win32 shell wrappers for close, async lock/swap, stylus/FPS updates, VR start/stop, window-state save, and the window-handle accessor now live in src/platform_windows/windows_platform_services.cpp instead of src/main.cpp, leaving main.cpp as a thinner entry/runtime dispatcher.

  • The entire CanvasModeGrid implementation plus ActionModeGrid undo/redo glue now live in src/legacy_canvas_mode_helpers.cpp instead of src/canvas_modes.cpp, leaving src/canvas_modes.cpp as a minimal shell.

  • App::request_close(), the RenderDoc frame wrappers, and the render/UI thread entrypoint wrappers now route through src/legacy_app_runtime_shell_services.* instead of staying inline in src/app.cpp, leaving app.cpp as a thinner retained app shell.

  • App::show_progress(), App::message_box(), and App::input_box() now route through src/legacy_app_dialog_services.* instead of building dialog plans and factories inline in src/app_dialogs.cpp.

  • The remaining generic Node event/capture/resize shell plus the width/height/padding/margin/flex/visibility/geometry wrappers now live in src/legacy_ui_node_execution.cpp and src/legacy_ui_node_style.* instead of staying inline in src/node.cpp, leaving node.cpp as a near-trivial attribute/load shell.

Current architecture mismatches that must be treated as real blockers:

  • pp_platform_api no longer compiles Apple implementation files, but it still owns too much concrete platform implementation instead of only platform-neutral policy and interface code.
  • src/platform_apple/apple_platform_services.cpp no longer reaches App::I directly, and Linux FPS title reporting now uses an injected callback, but retained Apple bridging in platform_legacy and other platform/app coupling remain, even though iOS keyboard visibility and prepared-file save handoff now also route through explicit Apple bridge callbacks and Apple render- context hooks plus iOS main-render-target binding now route through the same bridge style, as do Apple crash-test, app-close, and iOS SonarPen hooks, while Linux/Web GLFW render-context acquire/present and Linux app-close now route through retained local GLFW callback hooks, and retained Apple ObjC handles plus storage paths now sit behind one local platform_legacy helper instead of being re-read through App::I in each touched path, with the retained GLFW window hooks, Apple handle snapshots, and fallback storage-path return now also using local retained-state helpers instead of direct method-body reads, while Windows VR session snapshot state now also lives behind platform-owned helpers instead of on App, the platform_legacy-mirrored Apple/GLFW handle cluster is now seeded explicitly from platform entrypoints instead of being copied out of App, and retained storage roots are now also seeded explicitly instead of being lazily copied from App::I inside active_legacy_storage_paths(), while the retained Apple document bridge now also lives in legacy_platform_state.* instead of being built inline in legacy_platform_services.cpp.
  • src/platform_legacy/legacy_platform_services.* is still part of the live app shell.
  • pp_panopainter_ui still depends on pp_legacy_app.
  • Canvas, NodeCanvas, and NodeStrokePreview still own too much live OpenGL execution around the renderer boundary, even though NodeCanvas display resolve, cache-to-screen composite, post-draw mask/grid/current-mode sequencing, per-layer/per-plane retained draw execution, and shared checkerboard background setup now route through retained draw-merge helpers, with the cache-to-screen checkerboard-plane callback setup also reduced and the merged-path checkerboard background-plane callback plus per-plane merged-texture draw callback plus non-draw_merged per-frame layer draw callback plus the smoothing-mask face shader/draw pass plus heightmap, current-mode, and grid-mode callback setup now routed through the same retained helper family, while post-draw callback assembly and the remaining per-layer render-path orchestration now also route through retained draw-merge helpers even though the broader node draw loop is still inline, with the non-draw_merged outer layer/plane traversal now also routing through execute_legacy_canvas_draw_layer_traversal(...), while the heavier per-layer GL setup now also routes through make_legacy_canvas_draw_merge_layer_path_gl_execution(...) even though the remaining draw lambdas and broader node draw loop still live in src/node_canvas.cpp, where the post-draw/display-resolve tail now also routes through execute_node_canvas_draw_merge_tail(...), while the unmerged-path onion-range planning, plane filtering, per-layer visit handling, and per-visit layer-path execution now also route through execute_legacy_canvas_draw_unmerged_node_canvas_shell(...), while the broader unmerged cache/viewport/background/composite pass setup now also routes through execute_legacy_canvas_draw_unmerged_node_canvas_pass(...), while NodeCanvas::draw() setup, merged-pass shell, and unmerged-pass shell now also route through prepare_legacy_node_canvas_draw_setup(...), execute_legacy_canvas_draw_node_canvas_shell(...), execute_legacy_canvas_draw_merged_pass(...), and execute_legacy_canvas_draw_node_canvas_unmerged_pass(...), which materially shortens the live NodeCanvas::draw() body even though the file itself is still large.
  • app_layout.cpp and app_dialogs.cpp are still mixed shell/controller files rather than thin composition/binding surfaces, even though tools-menu binding plus nested panels/options submenu wiring now live in src/app_layout_tools_menu.cpp and App::init_menu_tools() is now a thin call-through, while file-menu binding plus the export submenu wiring now also live in src/app_layout_file_menu.cpp and App::init_menu_file() is now a thin call-through, while about-menu and layer-menu wiring now also live in src/app_layout_about_layer_menu.cpp and App::init_menu_about() plus App::init_menu_layer() are now thin call-throughs, while sidebar panel binding and popup wiring now also live in src/app_layout_sidebar.cpp and App::init_sidebar() is now a thin call-through, while main-toolbar binding now also lives in src/app_layout_main_toolbar.cpp and App::init_toolbar_main() is now a thin call-through, while edit-menu binding now also lives in src/app_layout_edit_menu.cpp and App::init_menu_edit() is now a thin call-through, while UI-direction and persisted floating/docked panel-state ownership now also live in src/app_layout_ui_state.cpp, while draw-toolbar binding now also lives in src/app_layout_draw_toolbar.cpp, while brush-refresh now also lives in src/app_layout_brush.cpp, while layout bootstrap plus reload/load continuation wiring now also lives in src/app_layout_bootstrap.cpp, and src/app_layout.cpp is now mostly thin call-through entrypoints, while the informational overlay opener family now also lives in src/app_dialogs_info_openers.cpp and the corresponding App::dialog_* entrypoints are thinner, while the export/video/PPBR dialog family now also lives in src/app_dialogs_export.cpp and those App::dialog_* entrypoints are thinner too, while new/open/save/browse/resize workflow entrypoints now also live in src/app_dialogs_workflow.cpp, while the layer-rename dialog open / wire / close pocket now lives in src/legacy_document_layer_services.*, and src/app_dialogs.cpp is now a thinner dialog dispatch surface.
  • App, Canvas, Node, retained workers, and platform entrypoints still use global singleton reach, raw observer pointers, retained static worker ownership in several app families, and ad hoc mutex/condition-variable ownership, even though most previously detached or raw app-facing worker launches now use owned std::jthread or service-owned worker queues and AppRuntime now owns render/UI workers with explicit std::jthread shutdown semantics while the Windows splash-dialog and HMD renderer workers also use owned std::jthread lifecycle, LogRemote now uses the same ownership model, the Windows VR device now has explicit std::unique_ptr ownership instead of raw global lifetime, and the Windows main-loop/VR coordination flags now use std::atomic instead of unsynchronized globals, while the main Win32 entrypoint now groups window/GL/task/VR state behind a retained local state object instead of separate process-wide globals, the Win32 async GL/context lock state now lives under src/platform_windows/windows_platform_services.cpp instead of main.cpp retained state, the main-thread queued task state now lives under src/platform_windows/windows_platform_services.cpp instead of staying in src/main.cpp, the canvas async worker now sits behind a named retained local worker-state helper instead of a bare static accessor, the prepared-file worker and the canvas async import/export/save/open worker now live under AppRuntime instead of retained static app-events/canvas workers, and the splash-screen dialog loop, HWND ownership, and bitmap setup now live in src/platform_windows/windows_splash.cpp instead of src/main.cpp, while Win32 pointer API loading, stylus/ink timer decay, Wintab packet reset, and WM_POINTERUPDATE pen/touch handling now also live in src/platform_windows/windows_stylus_input.cpp instead of src/main.cpp, while the retained Win32 VR/HMD shell now also routes through src/platform_windows/windows_vr_shell.h instead of staying inline in src/main.cpp, while RenderDoc startup/frame capture, SHCore DPI bootstrap, Win32 error-string conversion, the GL debug pre/post callbacks, and the WMI startup probe now also live in src/platform_windows/windows_bootstrap_helpers.cpp instead of src/main.cpp, while Win32 lifecycle running-state, close/shutdown sequencing, FPS title update/wakeup posting, stylus frame update, window preference save, and VR lifecycle wrappers now also live in src/platform_windows/windows_lifecycle_shell.cpp instead of src/main.cpp, while the Win32 startup/window/bootstrap path now also lives under src/platform_windows/windows_bootstrap_helpers.* for runtime-data discovery, startup-state initialization, window creation, pixel-format setup, GL loader init, runtime-info logging, and core-context upgrade sequencing, while BugTrap/SEH recovery setup now also lives in src/platform_windows/windows_bootstrap_helpers.cpp instead of src/main.cpp, while the Win32 window procedure and retained message-handling shell now also live in src/platform_windows/windows_window_shell.* instead of src/main.cpp, while the WinMain argv conversion bridge now also lives in src/platform_windows/windows_bootstrap_helpers.* instead of staying inline in src/main.cpp, while retained input-state zeroing and reverse key-map initialization now also live in src/platform_windows/windows_window_shell.* instead of src/main.cpp, while the remaining interactive Win32 runtime pocket for touch registration, render/UI thread startup, GL debug callback hookup, Wintab initialization/skip, icon setup, placement restore, optional VR start, splash dismissal, message loop, and shutdown cleanup now also lives in src/platform_windows/windows_runtime_shell.* instead of src/main.cpp, which materially thins src/main.cpp even though broader entrypoint/runtime composition still remains there, while App::rec_loop() now delegates worker-iteration orchestration into the retained recording bridge, App::update_rec_frames() now delegates recording label refresh through that same retained recording path, and the UI observer math, repeated UI child traversal, and canvas toolbar refresh now live in src/legacy_app_frame_services.cpp instead of staying inline in src/app.cpp, while the larger document/export/save/open/thumbnail document-IO cluster now lives in src/legacy_canvas_document_io_services.cpp and src/app.cpp is materially thinner, while App::clear(), App::check_license(), App::async_start(), App::async_redraw(), App::async_end(), and App::async_swap() now also live in src/legacy_app_runtime_shell_services.cpp instead of staying inline in src/app.cpp, while the canvas state-management cluster for picking, clear/clear-all, layer add/remove/order/lookups, animation frame control, resize, and snapshot save/restore now lives in src/legacy_canvas_state_services.cpp instead of src/canvas.cpp, while the larger import/export/save/open/thumbnail document-IO cluster now lives in src/legacy_canvas_document_io_services.cpp and NodeStrokePreview render-target setup plus immediate-pass sequencing now route through retained preview execution helpers, even though the bridge still owns worker-side readback flow and encoder-state label reads, while the main live-pass request assembly and framebuffer-copy setup now also route through src/legacy_node_stroke_preview_execution_services.h, even though broader preview-pass orchestration still lives in src/node_stroke_preview.cpp, while the immediate preview pass-sequencing family inside draw_stroke_immediate() now also routes through NodeStrokePreview::execute_stroke_draw_immediate_pass_sequence(...), while the remaining immediate preview pass shell now also routes through execute_legacy_node_stroke_preview_draw_immediate_shell(...), which materially reduces the live preview-pass body even though broader worker/readback flow still remains inline, while the immediate preview runtime/orchestration block for stroke setup, prepared-stroke construction, pass planning, shader setup, and live render request assembly now also routes through src/legacy_node_stroke_preview_runtime_services.* instead of staying inline in src/node_stroke_preview.cpp, while the low-level preview GL dispatch and texture-slot binding pocket now also routes through src/legacy_node_stroke_preview_runtime_services.* instead of staying inline in src/node_stroke_preview.cpp, while NodeStrokePreview remaining mix-pass planning and execution now also route through src/legacy_node_stroke_preview_draw_services.*, which trims the last dedicated mix-orchestration pocket from src/node_stroke_preview.cpp, while NodeCanvas::draw() unmerged-pass blend-gate, layer-orientation, and callback-assembly setup now also route through execute_node_canvas_draw_unmerged_pass(...), which trims another coherent unmerged draw shell from the live node even though the file itself remains large, while NodeCanvas::draw() merged-pass callback wiring and pass setup now also route through execute_node_canvas_draw_merged_pass(...), which trims another coherent merged draw shell from the live node even though the broader draw loop still remains inline, while Canvas::draw_objects_direct(...) and Canvas::draw_objects(...) now route through src/legacy_canvas_object_draw_services.* instead of staying inline in src/canvas.cpp, which trims another coherent object-draw and viewport-state execution family from the live canvas shell, while the static canvas plane geometry/orientation data now also lives in src/legacy_canvas_plane_data.cpp instead of staying inline in src/canvas.cpp, which trims another retained data-ownership pocket from the live canvas shell, while Canvas::stroke_draw_samples(...), Canvas::stroke_commit(), and the larger stroke commit/sample execution family now also route through src/legacy_canvas_stroke_commit_services.* instead of staying inline in src/canvas.cpp, which trims another large retained stroke-render and viewport-state execution family from the live canvas shell, while the live Canvas::stroke_draw() orchestration now also routes through src/legacy_canvas_stroke_live_services.cpp instead of staying inline in src/canvas.cpp, which materially thins another large retained live stroke-render pocket, while Canvas::layer_merge(...), Canvas::flood_fill(...), and Canvas::FloodData::apply() now also route through src/legacy_canvas_layer_services.cpp instead of staying inline in src/canvas.cpp, which trims another coherent retained layer/fill workflow pocket, while Canvas::stroke_end(...), Canvas::stroke_cancel(...), Canvas::stroke_draw_mix(...), Canvas::stroke_draw_project(...), Canvas::stroke_update(...), and Canvas::stroke_start(...) now also route through src/legacy_canvas_stroke_runtime_services.* instead of staying inline in src/canvas.cpp, which trims another large retained stroke/runtime pocket, while the final camera/timelapse member definitions now also live in src/legacy_canvas_camera_services.cpp instead of staying inline in src/canvas.cpp, which trims another retained canvas shell pocket, while the CanvasModeTransform interaction family now also routes through src/legacy_canvas_mode_transform.cpp instead of staying inline in src/canvas_modes.cpp, which materially thins another retained canvas-view and transform-mode execution pocket, while the CanvasModePen and CanvasModeLine interaction families now also route through src/legacy_canvas_mode_pen_line.cpp instead of staying inline in src/canvas_modes.cpp, while the CanvasModeFill and CanvasModeFloodFill interaction families now also route through src/legacy_canvas_mode_fill.cpp instead of staying inline in src/canvas_modes.cpp, which materially thins another retained fill-mode execution pocket from the broader canvas/render hotspot family, while NodePanelBrush save/restore/scan/reload/find/get-path ownership now routes through src/legacy_brush_panel_services.* instead of staying inline in src/node_panel_brush.cpp, which trims another retained brush-workflow pocket from the live UI node even though the broader panel still remains large, while NodePanelBrushPreset save/restore and package import/export/import-ABR routing now also lives in src/legacy_brush_preset_services.* instead of staying inline in src/node_panel_brush.cpp, which trims another large preset-workflow pocket from the live UI node, while NodePanelBrushPreset init/menu wiring, click handling, item construction, and added-state update now also route through src/legacy_brush_preset_panel_ui.* instead of staying inline in src/node_panel_brush.cpp, which materially thins another retained preset panel UI pocket, while the retained LegacyBrushPresetListServices block now also lives in src/legacy_brush_preset_list_services.* instead of staying inline in src/node_panel_brush.cpp, which trims another retained preset-list pocket, while NodeButtonBrush clone/init/icon/read/write/draw behavior and NodeBrushPresetItem clone/init/draw behavior now also live in src/legacy_brush_panel_item_ui.* instead of staying inline in src/node_panel_brush.cpp, which trims the remaining brush-item UI pocket from the live brush panel file, while NodePanelBrushPreset popup-close event handling now also lives in src/legacy_brush_preset_panel_ui.* instead of staying inline in src/node_panel_brush.cpp, which removes the last inline brush-panel popup close handler from the live node. The NodePanelBrushPreset registration/lifecycle pocket now also routes through the preset-list helper registry instead of a node-local static vector, which removes the remaining live preset-panel ownership glue from src/node_panel_brush.cpp, and preset-restore notification visibility now also stays with src/legacy_brush_preset_services.* instead of the node wrapper. The broader preset workflow pocket still remains, while NodeCanvas::handle_event() now also routes through execute_node_canvas_handle_event(...), which trims another coherent input-routing block from src/node_canvas.cpp even though the file is still a live canvas/controller shell, while NodeCanvas restore/clear context, resize handling, camera reset, buffer creation, cursor visibility/update, tick, and destroy ownership now also route through src/legacy_node_canvas_state_services.* instead of staying inline in src/node_canvas.cpp, which materially thins another retained state/control pocket, while shared canvas-mode GL wrappers plus the CanvasModeBasicCamera and CanvasModeCamera input handlers now also route through src/legacy_canvas_mode_helpers.* instead of staying inline in src/canvas_modes.cpp, while preview stroke preparation, dual-brush setup, and live pass-orchestration request assembly now also route through retained preview execution helpers, while NodeStrokePreview retained lifecycle, worker-thread shell, render-to-image path, on-screen handling, and preview texture ownership now also route through src/legacy_node_stroke_preview_runtime_services.cpp instead of staying inline in src/node_stroke_preview.cpp, while NodeCanvas::init() plus the remaining NodeCanvas::draw() outer shell now also route through src/legacy_node_canvas_draw_services.* instead of staying inline in src/node_canvas.cpp, which materially reduces the live node to a thinner controller surface around event routing and state wrappers, while Node::on_event(...) plus mouse/key capture and release ownership now also route through src/legacy_ui_node_event.* instead of staying inline in src/node.cpp, which materially thins the base scene-graph event shell without changing its public surface, while Node child attach/detach/reorder operations now route through named local helpers in src/node.cpp, and Node::load_internal(...) child XML loading now also routes through src/legacy_ui_node_loader.*, which makes the scene-graph mutation and child-instantiation paths easier to reason about without yet moving ownership into pp_ui_core, while the generic per-frame node execution/traversal family for restore_context, clear_context, update, update_internal, and tick now also lives in src/legacy_ui_node_execution.cpp, while Node::parse_attributes(...) now also routes through src/legacy_ui_node_attributes.* instead of staying inline in src/node.cpp, while the remaining generic Node lifecycle/state pocket for no-op lifecycle hooks, add/remove propagation, move construction, destruction cleanup, and base clone plumbing now also routes through src/legacy_ui_node_lifecycle.* instead of staying inline in src/node.cpp, while Canvas point-trace/unproject/project/camera push-pop-get-set and face-to-shape helpers now also route through src/legacy_canvas_projection_services.* instead of staying inline in src/canvas.cpp, while App::draw(...), App::update(...), App::terminate(...), App::update_memory_usage(...), App::update_rec_frames(...), App::res_from_index(...), App::res_to_index(...), App::res_to_string(...), App::rec_clear(...), App::rec_start(...), App::rec_stop(...), App::rec_export(...), App::rec_loop(...), and App::render_thread_tick(...) now also route through src/legacy_app_runtime_shell_services.cpp instead of staying inline in src/app.cpp, while Canvas::draw_merge(...), the temporary paint/branch orchestration helpers, final-plane composite, timelapse commit, create/destroy, clear-context, and camera accessors now also route through src/legacy_canvas_render_shell_services.* instead of staying inline in src/canvas.cpp, while Node::destroy(), root(), set_manager(...), added_to_root(), handle_on_screen(...), template loading helpers, child add/remove/move helpers, and child query helpers now also route through src/legacy_ui_node_tree_services.cpp instead of staying inline in src/node.cpp, while the CanvasModeMaskFree and CanvasModeMaskLine interaction families now also route through src/legacy_canvas_mode_mask.cpp instead of staying inline in src/canvas_modes.cpp, while the remaining live render/pass orchestration in NodeStrokePreview::draw_stroke_immediate() now also routes through src/legacy_node_stroke_preview_draw_services.* instead of staying inline in src/node_stroke_preview.cpp, and while the generic Yoga style/visibility pocket from Node::SetWidth(...) through Node::GetRTL() now also routes through src/legacy_ui_node_style.* instead of staying inline in src/node.cpp, while the preview sample execution pocket for sample-point conversion, brush vertex upload, request assembly, and the execute_legacy_canvas_stroke_sample(...) call now also lives in src/legacy_node_stroke_preview_sample_services.* instead of staying inline in src/node_stroke_preview.cpp.
  • Modern C++23 usage exists in extracted components, especially std::span, explicit result/status objects, and a few concepts, but the live app still does not consistently express ownership, thread affinity, or renderer resources through safe component contracts.

Conclusion:

  • the base component extraction is real
  • the app shell thinning is not done
  • the platform split is not done
  • the UI split is not done
  • the renderer/app ownership split is not done
  • future backend lab work is still premature until those four statements change

Final Target Architecture

The old roadmap showed a straight chain. That was too simple. The real target is a layered DAG with a thin composition root.

pp_foundation
  -> pp_assets
  -> pp_paint
  -> pp_document

pp_foundation
  -> pp_renderer_api
  -> pp_renderer_gl

pp_document + pp_paint + pp_renderer_api
  -> pp_paint_renderer

pp_foundation + pp_document
  -> pp_app_core

pp_foundation
  -> pp_ui_core

pp_platform_api
  -> pp_platform_windows
  -> pp_platform_apple
  -> pp_platform_linux
  -> pp_platform_android
  -> pp_platform_web
  -> pp_platform_vr

pp_app_core + pp_ui_core + pp_paint_renderer + pp_platform_api
  -> pp_panopainter_ui

pp_app_core + pp_panopainter_ui + pp_platform_*
  -> panopainter_app

Key ownership rules:

  • pp_platform_api is interface and policy only. No concrete platform service implementation files belong there.
  • pp_platform_* owns platform SDK, OS handles, platform event loops, and concrete service bridges.
  • pp_ui_core owns generic node/layout/overlay behavior and generic controls.
  • pp_panopainter_ui owns app-specific panels, dialogs, canvas views, and UI-to-app bindings.
  • pp_app_core owns planner logic, workflow policy, and service contracts. It does not own nodes, GL objects, or platform handles.
  • pp_paint_renderer owns renderer-facing paint/export/preview contracts.
  • panopainter_app owns composition only. It should stop being a second home for document workflow, dialog orchestration, platform state, or renderer execution.
  • Threading and task dispatch are app runtime services, not incidental static queues on App or detached workers launched from panels, dialogs, canvas, or cloud helpers.
  • UI ownership is handle/registry based at component boundaries. Raw Node* can remain as non-owning implementation detail only when lifetime is proven by checked handles or scoped connections.
  • Renderer-facing app code uses pp_renderer_api resources and command/context contracts. Texture2D, RTT, direct GL dispatch, and render-thread helpers must not leak into future-backend-facing UI or document code.

Workstreams

1. Break The Canvas And Preview Hotspots First

This is the highest-value work because it moves the largest concentration of real app behavior out of the old shell.

Required outcomes:

  • canvas.cpp loses major document-plus-render ownership
  • node_canvas.cpp and node_stroke_preview.cpp lose major render-pass orchestration
  • concrete GL execution moves behind renderer-facing services instead of living in app/node files

2. Thin The App Shell

app.cpp, app_layout.cpp, and app_dialogs.cpp must stop acting as mixed workflow, UI, and composition files.

Required outcomes:

  • app_layout.cpp becomes menu/toolbar binding composition
  • app_dialogs.cpp becomes workflow dispatch plus retained dialog opening glue
  • app.cpp becomes startup/frame/queue composition over named helpers

3. Finish The UI Core Split

pp_ui_core exists, but generic widget ownership is still incomplete.

Required outcomes:

  • generic Node and control code moves out of pp_legacy_ui_core
  • pp_panopainter_ui keeps only app-specific nodes
  • shared overlay/lifetime behavior stays centered in pp_ui_core
  • the scene graph has explicit ownership, non-owning references, scoped callback connections, and documented UI-thread affinity

4. Make Runtime And Thread Ownership Explicit

This is crucial for a modern app architecture and must move with the app-shell split, not after it.

Required outcomes:

  • render/UI/worker queues are owned by explicit runtime services
  • detached worker threads are replaced by joinable/cancellable ownership or a task service with shutdown semantics
  • render-thread and UI-thread access are expressed through small contracts that can be implemented by future platform shells
  • App::I and Canvas::I stop being the way cross-thread code reaches state

5. Finish The Platform Split

This is still a real blocker, but it should follow the bulk code-moving work above instead of taking priority over the main app hotspots.

Required outcomes:

  • remove concrete platform code from pp_platform_api
  • remove App::I reach from platform service implementations
  • remove app-owned cross-platform handle storage
  • reduce platform_legacy to thin composition or delete it

6. Retire Thick Workflow Bridges

Open/save/session/export/cloud/brush package flows are still too distributed across retained app, panel, and dialog files.

Required outcomes:

  • document workflow bridges become thin adapters over pp_app_core
  • cloud transfer and cloud browser ownership move out of retained UI nodes
  • brush package import/export ownership moves out of retained panel code and no longer depends on detached worker launch sites

7. Only Then Resume Future Backend Work

Vulkan, Metal, and package-only cleanup are explicitly downstream of the app architecture work above.

Do not treat future backend scaffolds as proof that modernization is near done while the current shell still depends on:

  • platform_legacy
  • pp_legacy_app
  • pp_legacy_ui_core
  • pp_legacy_paint_document
  • large GL-heavy node and canvas files

Exit Criteria

The modernization is not done until these are all true:

  • pp_platform_api contains only platform-neutral code
  • src/platform_apple/*, src/platform_linux/*, and other concrete platform implementations do not reach App::I
  • platform_legacy is gone or reduced to a trivial temporary shim
  • App no longer stores cross-platform handle state that belongs to platform shells
  • pp_panopainter_ui no longer depends on pp_legacy_app
  • panopainter_app is a composition root, not a second workflow layer
  • canvas.cpp, node_canvas.cpp, and node_stroke_preview.cpp no longer own large renderer-orchestration bodies
  • live app, UI, canvas, cloud, and platform code no longer launch detached worker threads without owned shutdown/cancellation
  • render/UI task queues are owned behind explicit app runtime services
  • raw app/UI pointers are non-owning implementation details only, backed by checked handles, scoped connections, or documented owner objects
  • future-backend-facing app and UI code depends on renderer API contracts, not retained OpenGL resource classes or direct GL execution
  • pp_legacy_ui_core, pp_legacy_app, and pp_legacy_paint_document are removed or reduced to narrow, explicit adapter seams with debt ids and clear removal conditions

Active Queue

Use docs/modernization/tasks.md for the current architecture task bundles, ordered by real code-moving priority. Use docs/modernization/tasks-done.md only for history.