<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://djroyleo.github.io/feed.xml" rel="self" type="application/atom+xml"/><link href="https://djroyleo.github.io/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-08-28T04:14:04+00:00</updated><id>https://djroyleo.github.io/feed.xml</id><title type="html">Dylan Joseph Roy-Leo</title><subtitle>A simple, whitespace theme for academics. Based on [*folio](https://github.com/bogoli/-folio) design. </subtitle><entry><title type="html">AeGIS Build-Out-Guide</title><link href="https://djroyleo.github.io/blog/2026/AeGIS-build-out-guide/" rel="alternate" type="text/html" title="AeGIS Build-Out-Guide"/><published>2026-08-08T00:00:00+00:00</published><updated>2026-08-08T00:00:00+00:00</updated><id>https://djroyleo.github.io/blog/2026/AeGIS-build-out-guide</id><content type="html" xml:base="https://djroyleo.github.io/blog/2026/AeGIS-build-out-guide/"><![CDATA[<h1 id="aegis-build-out-guide">AeGIS Build-Out Guide</h1> <p>This document is a complete set of instructions for building AeGIS out from its current skeleton into a full GIS application — both <strong>functionality</strong> (catalog, IO drivers, layer management, symbology, attribute tables, geoprocessing, project persistence, rendering) and <strong>GUI layout/design</strong> (owning the entire look of the app, and migrating from the current ArcGIS-Pro-imitation layout to a unique “artboard” workspace).</p> <p>It is written against the codebase as of commit <code class="language-plaintext highlighter-rouge">3ef338e</code> (“Restructured”). Code blocks in this document are <strong>sketches</strong> — they show shapes, names, and placement, not final code. Everything must still be adapted to pass the workspace lints (see §8).</p> <hr/> <h2 id="table-of-contents">Table of contents</h2> <ol> <li><a href="#1-architecture-today">Architecture today — what exists and the rules that bind it</a></li> <li><a href="#2-where-new-code-goes">Where new code goes — the placement decision guide</a></li> <li><a href="#3-prerequisite-refactor">Prerequisite refactor: application state and the command queue</a></li> <li><a href="#4-functionality-build-out">Functionality build-out</a> <ul> <li>4.1 <a href="#41-catalog-pane">Catalog pane: the pseudo file-management system</a></li> <li>4.2 <a href="#42-io-drivers">IO drivers: making files openable</a></li> <li>4.3 <a href="#43-adding-data-to-the-map">Adding data to the map</a></li> <li>4.4 <a href="#44-contents-panel">Contents panel: full layer management</a></li> <li>4.5 <a href="#45-symbology-pane">Symbology pane</a></li> <li>4.6 <a href="#46-attribute-table">Attribute table</a></li> <li>4.7 <a href="#47-geoprocessing">Geoprocessing toolbox and background jobs</a></li> <li>4.8 <a href="#48-project-saveload">Project save/load</a></li> <li>4.9 <a href="#49-map-navigation-extras">Map navigation extras</a></li> <li>4.10 <a href="#410-rendering-roadmap">Rendering roadmap</a></li> </ul> </li> <li><a href="#5-gui-ownership">GUI ownership</a> <ul> <li>5.1 <a href="#51-theming">Theming: full control of the look</a></li> <li>5.2 <a href="#52-custom-widgets">Custom widgets and window chrome</a></li> <li>5.3 <a href="#53-the-artboard-workspace">The artboard workspace</a></li> <li>5.4 <a href="#54-fixed-chrome">Fixed chrome: menu bar and status bar</a></li> </ul> </li> <li><a href="#6-error-surfacing">Error surfacing: the toast system</a></li> <li><a href="#7-new-dependencies">New dependencies and where they go</a></li> <li><a href="#8-cross-cutting-rules">Cross-cutting engineering rules</a></li> <li><a href="#9-suggested-build-order">Suggested build order</a></li> </ol> <hr/> <h2 id="1-architecture-today">1. Architecture today</h2> <h3 id="11-crate-dependency-graph">1.1 Crate dependency graph</h3> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                        ┌────────────────┐
                        │   aegis-app    │  ← binary; the ONLY crate that may
                        │ (eframe/egui)  │    depend on eframe/egui
                        └───┬──┬──┬──┬───┘
              ┌─────────────┘  │  │  └──────────────┐
              ▼                ▼  ▼                 ▼
      ┌──────────────┐   ┌───────────────┐   ┌──────────────┐
      │ aegis-render │──▶│ aegis-project │   │  aegis-io *  │
      │ (camera, f64 │   │ (Layer, Proj, │   │ (drivers,    │
      │  screen math)│   │  Symbology)   │   │  catalog)    │
      └──────┬───────┘   └───┬───────┬───┘   └───┬─────┬────┘
             │               │       │           │     │
             │        ┌──────┘       └────┐      │     │
             ▼        ▼                   ▼      ▼     │
      ┌────────────────────┐   ┌──────────────────┐    │
      │    aegis-vector    │   │   aegis-raster   │◀───┘
      │ (geo-types data)   │   │ (bands, geotx)   │
      └─────────┬──────────┘   └────────┬─────────┘
                │      ┌────────────────┘
                ▼      ▼
           ┌────────────────┐        ┌─────────────────────┐
           │   aegis-core   │        │ aegis-geoprocessing │──▶ vector/raster/core
           │ (Color, LayerId│        │ (tools + registry;  │
           │  Crs, Error)   │        │  nothing depends on │
           └────────────────┘        │  it yet except app*)│
                                     └─────────────────────┘

* aegis-io and aegis-geoprocessing are currently NOT dependencies of
  aegis-app. Wiring them in is part of this build-out (§4.1, §4.7).
</code></pre></div></div> <h3 id="12-the-invariants-do-not-break-these">1.2 The invariants (do not break these)</h3> <p>These rules are stated in the module docs and are the whole point of the restructure. Every feature below is designed around them:</p> <table> <thead> <tr> <th>#</th> <th>Rule</th> <th>Source</th> </tr> </thead> <tbody> <tr> <td>1</td> <td>Only <code class="language-plaintext highlighter-rouge">aegis-app</code> may depend on eframe/egui. Every other crate is pure data + logic.</td> <td><code class="language-plaintext highlighter-rouge">crates/aegis-app/src/main.rs</code> doc comment</td> </tr> <tr> <td>2</td> <td><code class="language-plaintext highlighter-rouge">aegis-render</code> is toolkit-agnostic: may one day depend on wgpu, must never depend on egui.</td> <td><code class="language-plaintext highlighter-rouge">crates/aegis-render/src/lib.rs</code></td> </tr> <tr> <td>3</td> <td>World coordinates are <code class="language-plaintext highlighter-rouge">f64</code> everywhere. The only f64→f32 narrowing point is <code class="language-plaintext highlighter-rouge">to_pos2</code> in <code class="language-plaintext highlighter-rouge">crates/aegis-app/src/render_egui/mod.rs</code>; widenings use <code class="language-plaintext highlighter-rouge">f64::from</code>.</td> <td>render_egui + workspace lints</td> </tr> <tr> <td>4</td> <td>Data and presentation are split: <code class="language-plaintext highlighter-rouge">aegis-vector</code>/<code class="language-plaintext highlighter-rouge">aegis-raster</code> hold pure datasets; name/visibility/symbology live on <code class="language-plaintext highlighter-rouge">aegis-project::Layer</code>. IO drivers speak <code class="language-plaintext highlighter-rouge">Dataset</code>, never <code class="language-plaintext highlighter-rouge">Layer</code>.</td> <td><code class="language-plaintext highlighter-rouge">aegis-io/src/lib.rs</code>, <code class="language-plaintext highlighter-rouge">aegis-project/src/lib.rs</code></td> </tr> <tr> <td>5</td> <td>No filesystem IO in GUI code. Directory walking, caching, and watching belong in <code class="language-plaintext highlighter-rouge">aegis_io::catalog</code>.</td> <td><code class="language-plaintext highlighter-rouge">aegis-io/src/catalog.rs</code> doc comment</td> </tr> <tr> <td>6</td> <td>GUI code references layers by <code class="language-plaintext highlighter-rouge">LayerId</code>, never by index or name.</td> <td><code class="language-plaintext highlighter-rouge">aegis-core/src/id.rs</code></td> </tr> <tr> <td>7</td> <td>Which files are openable is decided by <code class="language-plaintext highlighter-rouge">DriverRegistry</code>, never by a panel.</td> <td><code class="language-plaintext highlighter-rouge">aegis-app/src/panels/catalog.rs</code> doc comment</td> </tr> <tr> <td>8</td> <td>Nothing outside <code class="language-plaintext highlighter-rouge">render_egui</code> knows how layers become pixels (the renderer-swap seam).</td> <td><code class="language-plaintext highlighter-rouge">render_egui/mod.rs</code></td> </tr> <tr> <td>9</td> <td>Workspace-wide clippy: <code class="language-plaintext highlighter-rouge">pedantic</code> + <code class="language-plaintext highlighter-rouge">nursery</code> denied, plus the full no-panic set (<code class="language-plaintext highlighter-rouge">unwrap_used</code>, <code class="language-plaintext highlighter-rouge">expect_used</code>, <code class="language-plaintext highlighter-rouge">indexing_slicing</code>, <code class="language-plaintext highlighter-rouge">arithmetic_side_effects</code>, <code class="language-plaintext highlighter-rouge">as_conversions</code>, …). All new code must pass.</td> <td>root <code class="language-plaintext highlighter-rouge">Cargo.toml</code></td> </tr> </tbody> </table> <h3 id="13-what-each-crate-will-own-as-the-app-grows">1.3 What each crate will own as the app grows</h3> <table> <thead> <tr> <th>Crate</th> <th>Owns today</th> <th>Will own</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">aegis-core</code></td> <td><code class="language-plaintext highlighter-rouge">Color</code>, <code class="language-plaintext highlighter-rouge">LineStyle</code>, <code class="language-plaintext highlighter-rouge">FillStyle</code>, <code class="language-plaintext highlighter-rouge">LayerId</code>, <code class="language-plaintext highlighter-rouge">Crs</code>, <code class="language-plaintext highlighter-rouge">AegisError</code></td> <td>More id types (<code class="language-plaintext highlighter-rouge">MapViewId</code>, <code class="language-plaintext highlighter-rouge">PanelId</code>), more error variants; still zero dependencies</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">aegis-vector</code></td> <td><code class="language-plaintext highlighter-rouge">VectorDataset</code>, <code class="language-plaintext highlighter-rouge">Feature</code>, <code class="language-plaintext highlighter-rouge">Schema</code>, geometry re-exports</td> <td>Spatial indexing hooks, editing primitives</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">aegis-raster</code></td> <td><code class="language-plaintext highlighter-rouge">RasterDataset</code>, <code class="language-plaintext highlighter-rouge">Band</code>, <code class="language-plaintext highlighter-rouge">GeoTransform</code></td> <td>Overviews/pyramids, block IO, resampling</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">aegis-io</code></td> <td><code class="language-plaintext highlighter-rouge">DatasetDriver</code> trait, <code class="language-plaintext highlighter-rouge">DriverRegistry</code>, catalog <em>types</em></td> <td><strong>Directory listing + caching (§4.1.2), concrete drivers (§4.2), file watching</strong></td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">aegis-geoprocessing</code></td> <td><code class="language-plaintext highlighter-rouge">GeoprocessingTool</code> trait, <code class="language-plaintext highlighter-rouge">ParamSpec</code>, <code class="language-plaintext highlighter-rouge">ToolRegistry</code></td> <td><strong>Concrete tools, background job runner (§4.7)</strong></td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">aegis-project</code></td> <td><code class="language-plaintext highlighter-rouge">Layer</code>, <code class="language-plaintext highlighter-rouge">LayerSource</code>, <code class="language-plaintext highlighter-rouge">Symbology</code>, <code class="language-plaintext highlighter-rouge">Project</code></td> <td><strong>Selection state, undo history, save/load schema, multi-map documents</strong> (its own doc comment says selection/undo/save belong here, <em>not</em> in the GUI)</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">aegis-render</code></td> <td><code class="language-plaintext highlighter-rouge">MapCamera</code>, screen types, <code class="language-plaintext highlighter-rouge">MapRenderer</code> trait</td> <td><strong><code class="language-plaintext highlighter-rouge">fit_rect</code> camera math (§4.9), polygon triangulation (§4.10), the wgpu pipeline</strong></td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">aegis-app</code></td> <td>Panels, input plumbing, egui painter</td> <td><strong>All theming, the artboard workspace, dialogs, toasts; grows several modules but stays “presentation only”</strong></td> </tr> </tbody> </table> <hr/> <h2 id="2-where-new-code-goes">2. Where new code goes</h2> <p>Use this decision list for every new piece of functionality. It follows directly from the invariants:</p> <ol> <li><strong>Does it mention <code class="language-plaintext highlighter-rouge">egui</code>, <code class="language-plaintext highlighter-rouge">eframe</code>, colors-on-screen, pixels, fonts, windows, or input events?</strong> → <code class="language-plaintext highlighter-rouge">aegis-app</code>. No exceptions.</li> <li><strong>Is it math that turns world coordinates into screen coordinates (or back), camera motion, culling, tessellation, or GPU work?</strong> → <code class="language-plaintext highlighter-rouge">aegis-render</code> (all <code class="language-plaintext highlighter-rouge">f64</code>; no egui types — use <code class="language-plaintext highlighter-rouge">ScreenPoint</code>/<code class="language-plaintext highlighter-rouge">ScreenRect</code>).</li> <li><strong>Does it read or write files, list directories, or know about file formats?</strong> → <code class="language-plaintext highlighter-rouge">aegis-io</code>.</li> <li><strong>Does it transform datasets into datasets?</strong> → <code class="language-plaintext highlighter-rouge">aegis-geoprocessing</code> (as a <code class="language-plaintext highlighter-rouge">GeoprocessingTool</code> so the GUI dialog is auto-generated).</li> <li><strong>Is it about what the user’s document <em>is</em> (layers, order, visibility, symbology values, selection, undo)?</strong> → <code class="language-plaintext highlighter-rouge">aegis-project</code>.</li> <li><strong>Is it a raw data structure of spatial data?</strong> → <code class="language-plaintext highlighter-rouge">aegis-vector</code> / <code class="language-plaintext highlighter-rouge">aegis-raster</code>.</li> <li><strong>Is it a primitive every crate might need (id, color value, error variant)?</strong> → <code class="language-plaintext highlighter-rouge">aegis-core</code>.</li> </ol> <p><strong>Feature → crates-touched matrix</strong> for everything in this guide:</p> <table> <thead> <tr> <th>Feature</th> <th>core</th> <th>vector</th> <th>raster</th> <th>io</th> <th>geoproc</th> <th>project</th> <th>render</th> <th>app</th> </tr> </thead> <tbody> <tr> <td>Catalog file tree (§4.1)</td> <td> </td> <td> </td> <td> </td> <td>●</td> <td> </td> <td> </td> <td> </td> <td>●</td> </tr> <tr> <td>Folder picker modal (§4.1.4)</td> <td> </td> <td> </td> <td> </td> <td>●</td> <td> </td> <td> </td> <td> </td> <td>●</td> </tr> <tr> <td>GeoJSON driver (§4.2)</td> <td> </td> <td> </td> <td> </td> <td>●</td> <td> </td> <td> </td> <td> </td> <td> </td> </tr> <tr> <td>Add data to map (§4.3)</td> <td> </td> <td> </td> <td> </td> <td>●</td> <td> </td> <td>●</td> <td> </td> <td>●</td> </tr> <tr> <td>Contents interactions (§4.4)</td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td>●</td> <td>●</td> <td>●</td> </tr> <tr> <td>Symbology pane (§4.5)</td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td>●</td> <td> </td> <td>●</td> </tr> <tr> <td>Attribute table (§4.6)</td> <td> </td> <td>●</td> <td> </td> <td> </td> <td> </td> <td>●</td> <td> </td> <td>●</td> </tr> <tr> <td>Toolbox + jobs (§4.7)</td> <td>●</td> <td> </td> <td> </td> <td> </td> <td>●</td> <td>●</td> <td> </td> <td>●</td> </tr> <tr> <td>Project save/load (§4.8)</td> <td>●</td> <td>●</td> <td>●</td> <td> </td> <td> </td> <td>●</td> <td> </td> <td>●</td> </tr> <tr> <td>Zoom-to-extent (§4.9)</td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td>●</td> <td>●</td> </tr> <tr> <td>Polygon fill (§4.10)</td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td>●</td> <td>●</td> </tr> <tr> <td>Raster render (§4.10)</td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td>●</td> </tr> <tr> <td>wgpu pipeline (§4.10)</td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td>●</td> <td>●</td> </tr> <tr> <td>Theming (§5.1)</td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td>●</td> </tr> <tr> <td>Artboard (§5.3)</td> <td>●</td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td> </td> <td>●</td> </tr> </tbody> </table> <p>The pattern to notice: <strong>almost every feature is “logic in a lower crate + thin egui skin in <code class="language-plaintext highlighter-rouge">aegis-app</code>“</strong>. If a feature seems to need lots of code in <code class="language-plaintext highlighter-rouge">aegis-app</code>, part of it probably belongs lower.</p> <hr/> <h2 id="3-prerequisite-refactor">3. Prerequisite refactor</h2> <p>Do this <strong>before</strong> any feature work. The current <code class="language-plaintext highlighter-rouge">AegisApp</code> is two fields and the panels are stateless functions; every feature below needs per-panel state and a way for panels to affect the app without borrowing everything mutably at once.</p> <h3 id="31-grow-aegisapp-into-structured-state">3.1 Grow <code class="language-plaintext highlighter-rouge">AegisApp</code> into structured state</h3> <p>In <code class="language-plaintext highlighter-rouge">crates/aegis-app/src/app.rs</code>:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">struct</span> <span class="n">AegisApp</span> <span class="p">{</span>
    <span class="c1">// Document</span>
    <span class="n">project</span><span class="p">:</span> <span class="n">Project</span><span class="p">,</span>

    <span class="c1">// Services (built once at startup)</span>
    <span class="n">drivers</span><span class="p">:</span> <span class="nn">std</span><span class="p">::</span><span class="nn">sync</span><span class="p">::</span><span class="nb">Arc</span><span class="o">&lt;</span><span class="nn">aegis_io</span><span class="p">::</span><span class="n">DriverRegistry</span><span class="o">&gt;</span><span class="p">,</span>
    <span class="n">tools</span><span class="p">:</span> <span class="nn">std</span><span class="p">::</span><span class="nn">sync</span><span class="p">::</span><span class="nb">Arc</span><span class="o">&lt;</span><span class="nn">aegis_geoprocessing</span><span class="p">::</span><span class="n">ToolRegistry</span><span class="o">&gt;</span><span class="p">,</span>

    <span class="c1">// Per-view state</span>
    <span class="n">map_views</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">MapViewState</span><span class="o">&gt;</span><span class="p">,</span>        <span class="c1">// starts with one; artboard uses many</span>
    <span class="n">catalog</span><span class="p">:</span> <span class="n">CatalogState</span><span class="p">,</span>               <span class="c1">// §4.1</span>
    <span class="n">workspace</span><span class="p">:</span> <span class="n">WorkspaceState</span><span class="p">,</span>           <span class="c1">// §5.3 (artboard); trivial until then</span>
    <span class="n">toasts</span><span class="p">:</span> <span class="n">Toasts</span><span class="p">,</span>                      <span class="c1">// §6</span>

    <span class="c1">// Transient UI state</span>
    <span class="n">commands</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">AppCommand</span><span class="o">&gt;</span><span class="p">,</span>           <span class="c1">// §3.2</span>
    <span class="n">dialogs</span><span class="p">:</span> <span class="n">DialogState</span><span class="p">,</span>                <span class="c1">// open modal, if any (§4.1.4)</span>
<span class="p">}</span>

<span class="k">pub</span> <span class="k">struct</span> <span class="n">MapViewState</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="n">id</span><span class="p">:</span> <span class="n">MapViewId</span><span class="p">,</span>                   <span class="c1">// new id type in aegis-core (§5.3.5)</span>
    <span class="k">pub</span> <span class="n">name</span><span class="p">:</span> <span class="nb">String</span><span class="p">,</span>
    <span class="k">pub</span> <span class="n">camera</span><span class="p">:</span> <span class="n">MapCamera</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div> <p>Registries are built in <code class="language-plaintext highlighter-rouge">main.rs</code> / <code class="language-plaintext highlighter-rouge">AegisApp::default()</code> and wrapped in <code class="language-plaintext highlighter-rouge">Arc</code> because the geoprocessing job runner (§4.7) will need to share them with worker threads.</p> <h3 id="32-the-command-queue">3.2 The command queue</h3> <p>egui is immediate-mode: a context-menu click inside the catalog panel happens while the panel borrows part of <code class="language-plaintext highlighter-rouge">self</code>. If that click must mutate <code class="language-plaintext highlighter-rouge">project</code>, you either fight the borrow checker or you queue a command. <strong>Queue a command.</strong> Define in a new <code class="language-plaintext highlighter-rouge">crates/aegis-app/src/command.rs</code>:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">enum</span> <span class="n">AppCommand</span> <span class="p">{</span>
    <span class="nf">AddLayer</span><span class="p">(</span><span class="n">Layer</span><span class="p">),</span>
    <span class="nf">RemoveLayer</span><span class="p">(</span><span class="n">LayerId</span><span class="p">),</span>
    <span class="nf">SelectLayer</span><span class="p">(</span><span class="nb">Option</span><span class="o">&lt;</span><span class="n">LayerId</span><span class="o">&gt;</span><span class="p">),</span>
    <span class="n">ZoomTo</span> <span class="p">{</span> <span class="n">view</span><span class="p">:</span> <span class="n">MapViewId</span><span class="p">,</span> <span class="n">rect</span><span class="p">:</span> <span class="nn">geo_types</span><span class="p">::</span><span class="n">Rect</span><span class="o">&lt;</span><span class="nb">f64</span><span class="o">&gt;</span> <span class="p">},</span>
    <span class="nf">OpenDialog</span><span class="p">(</span><span class="n">Dialog</span><span class="p">),</span>                  <span class="c1">// e.g. Dialog::FolderPicker(picker)</span>
    <span class="nf">OpenPanel</span><span class="p">(</span><span class="n">PanelKind</span><span class="p">),</span>                <span class="c1">// §5.3</span>
    <span class="nf">Toast</span><span class="p">(</span><span class="n">ToastLevel</span><span class="p">,</span> <span class="nb">String</span><span class="p">),</span>
    <span class="n">RunTool</span> <span class="p">{</span> <span class="n">tool_id</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">'static</span> <span class="nb">str</span><span class="p">,</span> <span class="n">args</span><span class="p">:</span> <span class="n">ToolArgs</span> <span class="p">},</span>
    <span class="c1">// ...grows with the app</span>
<span class="p">}</span>
</code></pre></div></div> <p>Every panel <code class="language-plaintext highlighter-rouge">show</code>/<code class="language-plaintext highlighter-rouge">ui</code> function takes <code class="language-plaintext highlighter-rouge">&amp;mut Vec&lt;AppCommand&gt;</code> (or a small <code class="language-plaintext highlighter-rouge">CommandSink</code> newtype) and pushes into it instead of mutating distant state. At the end of <code class="language-plaintext highlighter-rouge">AegisApp::ui</code>, drain and apply:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="n">cmd</span> <span class="k">in</span> <span class="nn">std</span><span class="p">::</span><span class="nn">mem</span><span class="p">::</span><span class="nf">take</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="py">.commands</span><span class="p">)</span> <span class="p">{</span> <span class="k">self</span><span class="nf">.apply</span><span class="p">(</span><span class="n">cmd</span><span class="p">);</span> <span class="p">}</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">apply</code> is the <strong>single place</strong> the project and app state are mutated in response to UI. This one pattern makes the catalog (§4.1), context menus (§4.4), tool dialogs (§4.7), and the artboard (§5.3) all straightforward.</p> <p>(Exception: cheap, local mutations like the visibility checkbox in the Contents panel may keep mutating <code class="language-plaintext highlighter-rouge">&amp;mut Project</code> directly — don’t dogmatize — but anything that crosses panel boundaries goes through the queue.)</p> <h3 id="33-split-panel-content-from-panel-container">3.3 Split “panel content” from “panel container”</h3> <p>Today each file in <code class="language-plaintext highlighter-rouge">crates/aegis-app/src/panels/</code> both <em>docks itself</em> (calls <code class="language-plaintext highlighter-rouge">egui::Panel::left(...)</code>) and <em>draws its content</em>. The artboard (§5.3) will put the same content in floating windows instead. Prepare now, cheaply:</p> <ul> <li>Each panel module exposes <code class="language-plaintext highlighter-rouge">pub fn ui(ui: &amp;mut egui::Ui, ...state...)</code> that draws <strong>only the content</strong> into whatever <code class="language-plaintext highlighter-rouge">Ui</code> it is given.</li> <li>The docking decision (<code class="language-plaintext highlighter-rouge">egui::Panel::right("right_panel")…</code>) moves up into <code class="language-plaintext highlighter-rouge">app.rs</code> (or later, into the workspace module).</li> </ul> <p><code class="language-plaintext highlighter-rouge">map_view::show</code> already has exactly this shape (it draws into <code class="language-plaintext highlighter-rouge">ui.available_size()</code> wherever it’s called) — mirror that for the others. This refactor is ~30 minutes now and saves rewriting every panel later.</p> <hr/> <h2 id="4-functionality-build-out">4. Functionality build-out</h2> <h3 id="41-catalog-pane-the-pseudo-file-management-system">4.1 Catalog pane: the pseudo file-management system</h3> <p>Goal (restating the requirement precisely):</p> <ol> <li>Remove the always-visible “Add folder connection” button.</li> <li>Right-clicking the catalog pane’s empty background opens a context menu with <strong>Add folder connection</strong>.</li> <li>Choosing it opens a <strong>centered modal folder browser</strong> over the app.</li> <li>Confirming a folder adds it as a root item in the catalog tree.</li> <li>Each root is expandable: files are listed by name; subfolders are expandable items, recursively, lazily.</li> </ol> <p>This feature splits cleanly across the io/app boundary:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>aegis-io  (all filesystem logic)          aegis-app (all widgets)
─────────────────────────────────         ─────────────────────────────
FolderConnection      (exists)            CatalogState (connections,
CatalogEntry          (exists)              expanded-cache handle)
list_dir()            (new)               panels/catalog.rs (tree UI,
CatalogCache          (new)                 background context menu)
                                          dialogs/folder_picker.rs (new)
</code></pre></div></div> <h4 id="411-aegis-io-directory-listing">4.1.1 <code class="language-plaintext highlighter-rouge">aegis-io</code>: directory listing</h4> <p>Add to <code class="language-plaintext highlighter-rouge">crates/aegis-io/src/catalog.rs</code>:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">/// List one directory as catalog rows: folders first, then files,</span>
<span class="cd">/// each alphabetically (case-insensitive). Files are classified by the</span>
<span class="cd">/// registry: a file some driver claims becomes `CatalogEntry::Dataset`,</span>
<span class="cd">/// anything else `CatalogEntry::Unrecognized`.</span>
<span class="cd">///</span>
<span class="cd">/// # Errors</span>
<span class="cd">/// `AegisError::Io` if the directory cannot be read.</span>
<span class="k">pub</span> <span class="k">fn</span> <span class="nf">list_dir</span><span class="p">(</span>
    <span class="n">path</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">Path</span><span class="p">,</span>
    <span class="n">registry</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">DriverRegistry</span><span class="p">,</span>
<span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Result</span><span class="o">&lt;</span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">CatalogEntry</span><span class="o">&gt;</span><span class="p">,</span> <span class="n">AegisError</span><span class="o">&gt;</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>
</code></pre></div></div> <p>Implementation notes:</p> <ul> <li><code class="language-plaintext highlighter-rouge">std::fs::read_dir(path)?</code> — the <code class="language-plaintext highlighter-rouge">?</code> maps into <code class="language-plaintext highlighter-rouge">AegisError::Io</code> via the existing <code class="language-plaintext highlighter-rouge">From&lt;std::io::Error&gt;</code> impl. Per-entry errors (<code class="language-plaintext highlighter-rouge">entry?</code>): skip the entry (a vanished file mid-iteration is not fatal) — but do it without <code class="language-plaintext highlighter-rouge">unwrap</code>; a <code class="language-plaintext highlighter-rouge">filter_map</code> over <code class="language-plaintext highlighter-rouge">Result::ok</code> is fine and lint-clean.</li> <li>Classification: <code class="language-plaintext highlighter-rouge">entry.file_type()</code> for dir/file; <code class="language-plaintext highlighter-rouge">registry.driver_for(&amp;path)</code> decides <code class="language-plaintext highlighter-rouge">Dataset { path, driver }</code> vs <code class="language-plaintext highlighter-rouge">Unrecognized</code> (rule 7 — the panel never looks at extensions).</li> <li>Sorting: sort by <code class="language-plaintext highlighter-rouge">(is_file, name.to_lowercase())</code>. Hidden files (leading <code class="language-plaintext highlighter-rouge">.</code>): skip by default; make it a parameter (<code class="language-plaintext highlighter-rouge">ListOptions { show_hidden }</code>) so the GUI can add a toggle later.</li> <li><strong>Multi-file formats</strong>: shapefiles are <code class="language-plaintext highlighter-rouge">.shp</code> + <code class="language-plaintext highlighter-rouge">.dbf</code> + <code class="language-plaintext highlighter-rouge">.shx</code> + …; when a driver claims <code class="language-plaintext highlighter-rouge">.shp</code>, the sidecar files should ideally be hidden. Add an optional <code class="language-plaintext highlighter-rouge">fn sidecar_extensions(&amp;self) -&gt; &amp;[&amp;'static str] { &amp;[] }</code> method to <code class="language-plaintext highlighter-rouge">DatasetDriver</code> and have <code class="language-plaintext highlighter-rouge">list_dir</code> suppress files whose extension is a sidecar of a claimed sibling. Defer this until the shapefile driver lands, but know it goes <em>here</em>, not in the panel.</li> </ul> <h4 id="412-aegis-io-the-cache">4.1.2 <code class="language-plaintext highlighter-rouge">aegis-io</code>: the cache</h4> <p>Walking the filesystem on every frame is out of the question (egui redraws at input rate). Add a cache, also in <code class="language-plaintext highlighter-rouge">catalog.rs</code> (or a new <code class="language-plaintext highlighter-rouge">catalog/cache.rs</code>):</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">/// Memoized directory listings for the catalog tree.</span>
<span class="nd">#[derive(Default)]</span>
<span class="k">pub</span> <span class="k">struct</span> <span class="n">CatalogCache</span> <span class="p">{</span>
    <span class="n">entries</span><span class="p">:</span> <span class="n">HashMap</span><span class="o">&lt;</span><span class="n">PathBuf</span><span class="p">,</span> <span class="nb">Result</span><span class="o">&lt;</span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">CatalogEntry</span><span class="o">&gt;</span><span class="p">,</span> <span class="n">AegisError</span><span class="o">&gt;&gt;</span><span class="p">,</span>
<span class="p">}</span>

<span class="k">impl</span> <span class="n">CatalogCache</span> <span class="p">{</span>
    <span class="cd">/// The cached listing for `path`, computing it on first access.</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">entries</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">path</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">Path</span><span class="p">,</span> <span class="n">registry</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">DriverRegistry</span><span class="p">)</span>
        <span class="k">-&gt;</span> <span class="o">&amp;</span><span class="nb">Result</span><span class="o">&lt;</span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">CatalogEntry</span><span class="o">&gt;</span><span class="p">,</span> <span class="n">AegisError</span><span class="o">&gt;</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>

    <span class="cd">/// Drop the cached listing for `path` (and descendants), forcing a</span>
    <span class="cd">/// re-read on next access. Wire this to a "Refresh" context-menu item.</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">invalidate</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">path</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">Path</span><span class="p">)</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>

    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">clear</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>Caching the <code class="language-plaintext highlighter-rouge">Err</code> case matters: an unreadable directory must not retry every frame. Storing <code class="language-plaintext highlighter-rouge">AegisError</code> directly is fine (it’s not <code class="language-plaintext highlighter-rouge">Clone</code>; the getter returns a reference). Future work that slots in here without touching the panel: <code class="language-plaintext highlighter-rouge">notify</code>-based file watching that calls <code class="language-plaintext highlighter-rouge">invalidate</code> automatically.</p> <h4 id="413-aegis-app-catalog-state-and-the-tree-ui">4.1.3 <code class="language-plaintext highlighter-rouge">aegis-app</code>: catalog state and the tree UI</h4> <p>New state (in <code class="language-plaintext highlighter-rouge">panels/catalog.rs</code> or a <code class="language-plaintext highlighter-rouge">state.rs</code>):</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">struct</span> <span class="n">CatalogState</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="n">connections</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="nn">aegis_io</span><span class="p">::</span><span class="n">FolderConnection</span><span class="o">&gt;</span><span class="p">,</span>
    <span class="k">pub</span> <span class="n">cache</span><span class="p">:</span> <span class="nn">aegis_io</span><span class="p">::</span><span class="n">CatalogCache</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div> <p>Rewrite <code class="language-plaintext highlighter-rouge">panels/catalog.rs::show</code> → <code class="language-plaintext highlighter-rouge">ui(ui, &amp;mut CatalogState, &amp;DriverRegistry, &amp;mut Vec&lt;AppCommand&gt;)</code>:</p> <p><strong>(a) The background context menu.</strong> Two-layer approach so it works both on rows and on empty space:</p> <ul> <li>Draw the tree first (inside a <code class="language-plaintext highlighter-rouge">egui::ScrollArea::vertical()</code>).</li> <li>Then claim the <em>remaining</em> space for interaction and attach the menu:</li> </ul> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">bg</span> <span class="o">=</span> <span class="n">ui</span><span class="nf">.interact</span><span class="p">(</span>
    <span class="n">ui</span><span class="nf">.available_rect_before_wrap</span><span class="p">()</span><span class="nf">.union</span><span class="p">(</span><span class="cm">/* or just */</span> <span class="n">ui</span><span class="nf">.max_rect</span><span class="p">()),</span>
    <span class="n">ui</span><span class="nf">.id</span><span class="p">()</span><span class="nf">.with</span><span class="p">(</span><span class="s">"catalog_bg"</span><span class="p">),</span>
    <span class="nn">egui</span><span class="p">::</span><span class="nn">Sense</span><span class="p">::</span><span class="nf">click</span><span class="p">(),</span>
<span class="p">);</span>
<span class="n">bg</span><span class="nf">.context_menu</span><span class="p">(|</span><span class="n">ui</span><span class="p">|</span> <span class="p">{</span>
    <span class="k">if</span> <span class="n">ui</span><span class="nf">.button</span><span class="p">(</span><span class="s">"Add folder connection"</span><span class="p">)</span><span class="nf">.clicked</span><span class="p">()</span> <span class="p">{</span>
        <span class="n">commands</span><span class="nf">.push</span><span class="p">(</span><span class="nn">AppCommand</span><span class="p">::</span><span class="nf">OpenDialog</span><span class="p">(</span><span class="nn">Dialog</span><span class="p">::</span><span class="nf">FolderPicker</span><span class="p">(</span>
            <span class="nn">FolderPicker</span><span class="p">::</span><span class="nf">starting_at_home</span><span class="p">(),</span>
        <span class="p">)));</span>
        <span class="n">ui</span><span class="nf">.close</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">ui</span><span class="nf">.button</span><span class="p">(</span><span class="s">"Refresh"</span><span class="p">)</span><span class="nf">.clicked</span><span class="p">()</span> <span class="p">{</span> <span class="n">state</span><span class="py">.cache</span><span class="nf">.clear</span><span class="p">();</span> <span class="n">ui</span><span class="nf">.close</span><span class="p">();</span> <span class="p">}</span>
<span class="p">});</span>
</code></pre></div></div> <p>Notes: <code class="language-plaintext highlighter-rouge">ui.max_rect()</code> after content = the whole panel including empty space; using <code class="language-plaintext highlighter-rouge">interact</code> <em>after</em> drawing the tree keeps row-level interactions (added in (c)) on top. Delete the old “Add folder connection” button entirely; the <code class="language-plaintext highlighter-rouge">ui.label("Catalog")</code> header can stay or become a styled heading (§5.1).</p> <p><strong>(b) The tree.</strong> Recursive function; <code class="language-plaintext highlighter-rouge">egui::CollapsingHeader</code> gives you expand/collapse with state kept by egui, keyed by id — <strong>key it with the full path</strong> so two folders named <code class="language-plaintext highlighter-rouge">data</code> don’t share state:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">folder_ui</span><span class="p">(</span><span class="n">ui</span><span class="p">,</span> <span class="n">path</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">Path</span><span class="p">,</span> <span class="n">state</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="n">CatalogState</span><span class="p">,</span>
             <span class="n">drivers</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">DriverRegistry</span><span class="p">,</span> <span class="n">commands</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">AppCommand</span><span class="o">&gt;</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">name</span> <span class="o">=</span> <span class="n">path</span><span class="nf">.file_name</span><span class="p">()</span><span class="nf">.map_or_else</span><span class="p">(</span>
        <span class="p">||</span> <span class="n">path</span><span class="nf">.display</span><span class="p">()</span><span class="nf">.to_string</span><span class="p">(),</span>
        <span class="p">|</span><span class="n">n</span><span class="p">|</span> <span class="n">n</span><span class="nf">.to_string_lossy</span><span class="p">()</span><span class="nf">.into_owned</span><span class="p">());</span>
    <span class="nn">egui</span><span class="p">::</span><span class="nn">CollapsingHeader</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="n">name</span><span class="p">)</span>
        <span class="nf">.id_salt</span><span class="p">(</span><span class="n">path</span><span class="p">)</span>                 <span class="c1">// ← disambiguates identical names</span>
        <span class="nf">.show</span><span class="p">(</span><span class="n">ui</span><span class="p">,</span> <span class="p">|</span><span class="n">ui</span><span class="p">|</span> <span class="p">{</span>
            <span class="c1">// Lazy: this closure only runs when expanded, so the cache is</span>
            <span class="c1">// only populated for folders the user actually opens.</span>
            <span class="k">match</span> <span class="n">state</span><span class="py">.cache</span><span class="nf">.entries</span><span class="p">(</span><span class="n">path</span><span class="p">,</span> <span class="n">drivers</span><span class="p">)</span> <span class="p">{</span>
                <span class="nf">Ok</span><span class="p">(</span><span class="n">entries</span><span class="p">)</span> <span class="k">=&gt;</span> <span class="p">{</span>
                    <span class="c1">// NOTE: iterate a clone of the entry list (they're small)</span>
                    <span class="c1">// or collect child paths first — you cannot hold a borrow</span>
                    <span class="c1">// of `state.cache` while recursing with `&amp;mut state`.</span>
                    <span class="k">for</span> <span class="n">entry</span> <span class="k">in</span> <span class="n">entries</span><span class="nf">.clone</span><span class="p">()</span> <span class="p">{</span>
                        <span class="k">match</span> <span class="n">entry</span> <span class="p">{</span>
                            <span class="nn">CatalogEntry</span><span class="p">::</span><span class="nf">Folder</span><span class="p">(</span><span class="n">p</span><span class="p">)</span> <span class="k">=&gt;</span>
                                <span class="nf">folder_ui</span><span class="p">(</span><span class="n">ui</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">p</span><span class="p">,</span> <span class="n">state</span><span class="p">,</span> <span class="n">drivers</span><span class="p">,</span> <span class="n">commands</span><span class="p">),</span>
                            <span class="nn">CatalogEntry</span><span class="p">::</span><span class="n">Dataset</span> <span class="p">{</span> <span class="n">path</span><span class="p">,</span> <span class="n">driver</span> <span class="p">}</span> <span class="k">=&gt;</span>
                                <span class="nf">dataset_row_ui</span><span class="p">(</span><span class="n">ui</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">path</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">driver</span><span class="p">,</span> <span class="n">commands</span><span class="p">),</span>
                            <span class="nn">CatalogEntry</span><span class="p">::</span><span class="nf">Unrecognized</span><span class="p">(</span><span class="n">p</span><span class="p">)</span> <span class="k">=&gt;</span>
                                <span class="p">{</span> <span class="n">ui</span><span class="nf">.weak</span><span class="p">(</span><span class="nf">file_name_of</span><span class="p">(</span><span class="o">&amp;</span><span class="n">p</span><span class="p">));</span> <span class="p">}</span>
                        <span class="p">}</span>
                    <span class="p">}</span>
                <span class="p">}</span>
                <span class="nf">Err</span><span class="p">(</span><span class="n">e</span><span class="p">)</span> <span class="k">=&gt;</span> <span class="p">{</span> <span class="n">ui</span><span class="nf">.colored_label</span><span class="p">(</span><span class="n">ui</span><span class="nf">.visuals</span><span class="p">()</span><span class="py">.error_fg_color</span><span class="p">,</span>
                                             <span class="n">e</span><span class="nf">.to_string</span><span class="p">());</span> <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">});</span>
<span class="p">}</span>
</code></pre></div></div> <p>Top level: one <code class="language-plaintext highlighter-rouge">folder_ui</code> per <code class="language-plaintext highlighter-rouge">state.connections</code> entry, but root rows should display the <strong>full path</strong> and carry their own context menu with <strong>Remove connection</strong> (pushes a command; removal also calls <code class="language-plaintext highlighter-rouge">cache.invalidate</code>). The borrow dance flagged in the comment is real — the clean solution is <code class="language-plaintext highlighter-rouge">let entries = state.cache.entries(...).clone()</code> for the <code class="language-plaintext highlighter-rouge">Ok</code> arm (make <code class="language-plaintext highlighter-rouge">list_dir</code> results cheap: <code class="language-plaintext highlighter-rouge">CatalogEntry</code> is already <code class="language-plaintext highlighter-rouge">Clone</code>).</p> <p><strong>(c) Dataset rows</strong> get <code class="language-plaintext highlighter-rouge">Sense::click()</code> labels with:</p> <ul> <li>a context menu: <strong>Add to map</strong> (§4.3), later <em>Properties</em>;</li> <li><code class="language-plaintext highlighter-rouge">double_clicked()</code> → also Add to map (the GIS convention);</li> <li>the driver name shown as a subtle badge/weak text on the right (<code class="language-plaintext highlighter-rouge">ui.horizontal</code> + <code class="language-plaintext highlighter-rouge">ui.weak(driver)</code>), which doubles as debug feedback that the registry classification works.</li> </ul> <p><code class="language-plaintext highlighter-rouge">Unrecognized</code> files render <code class="language-plaintext highlighter-rouge">ui.weak(...)</code> (greyed out), matching the intent in <code class="language-plaintext highlighter-rouge">CatalogEntry</code>’s doc. Optionally hide them behind a “Show all files” toggle stored in <code class="language-plaintext highlighter-rouge">CatalogState</code>.</p> <h4 id="414-the-folder-picker-modal">4.1.4 The folder-picker modal</h4> <p>Build AeGIS’s <strong>own</strong> egui folder browser rather than using the native OS dialog (<code class="language-plaintext highlighter-rouge">rfd</code>). Rationale: (1) it keeps the look 100% AeGIS-owned, which is the explicit direction of §5; (2) no new native dependencies; (3) the same widget gets reused for <code class="language-plaintext highlighter-rouge">ParamKind::Path</code> in tool dialogs (§4.7) and for project save/load (§4.8). If you want a stopgap first, <code class="language-plaintext highlighter-rouge">rfd::FileDialog</code> (dependency in <code class="language-plaintext highlighter-rouge">aegis-app</code> only) works in one line — but treat it as temporary. <em>(Note: <code class="language-plaintext highlighter-rouge">rfd</code>’s blocking <code class="language-plaintext highlighter-rouge">pick_folder</code> freezes the UI thread while open; that’s acceptable for a stopgap modal but is another reason the custom picker wins.)</em></p> <p>New module <code class="language-plaintext highlighter-rouge">crates/aegis-app/src/dialogs/folder_picker.rs</code>:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">struct</span> <span class="n">FolderPicker</span> <span class="p">{</span>
    <span class="n">current</span><span class="p">:</span> <span class="n">PathBuf</span><span class="p">,</span>               <span class="c1">// directory being viewed</span>
    <span class="n">selection</span><span class="p">:</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="n">PathBuf</span><span class="o">&gt;</span><span class="p">,</span>     <span class="c1">// single-clicked subfolder, if any</span>
    <span class="n">error</span><span class="p">:</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="nb">String</span><span class="o">&gt;</span><span class="p">,</span>          <span class="c1">// last navigation error, shown inline</span>
<span class="p">}</span>

<span class="k">pub</span> <span class="k">enum</span> <span class="n">PickerResult</span> <span class="p">{</span> <span class="n">Open</span><span class="p">,</span> <span class="n">Cancelled</span><span class="p">,</span> <span class="nf">Picked</span><span class="p">(</span><span class="n">PathBuf</span><span class="p">)</span> <span class="p">}</span>

<span class="k">impl</span> <span class="n">FolderPicker</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">starting_at_home</span><span class="p">()</span> <span class="k">-&gt;</span> <span class="k">Self</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>   <span class="c1">// std::env::home_dir()</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">ui</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">ctx</span><span class="p">:</span> <span class="o">&amp;</span><span class="nn">egui</span><span class="p">::</span><span class="n">Context</span><span class="p">,</span>
              <span class="n">cache</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="n">CatalogCache</span><span class="p">,</span> <span class="n">drivers</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">DriverRegistry</span><span class="p">)</span>
              <span class="k">-&gt;</span> <span class="n">PickerResult</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p>Layout of the modal (use <code class="language-plaintext highlighter-rouge">egui::Modal</code> — it dims the background, centers itself, and closes on outside-click/Escape; if its behavior doesn’t fit, fall back to <code class="language-plaintext highlighter-rouge">egui::Window</code> with <code class="language-plaintext highlighter-rouge">.pivot(Align2::CENTER_CENTER)</code>, <code class="language-plaintext highlighter-rouge">.fixed_pos(ctx.screen_rect().center())</code>, <code class="language-plaintext highlighter-rouge">.collapsible(false)</code>, <code class="language-plaintext highlighter-rouge">.resizable(true)</code> and <code class="language-plaintext highlighter-rouge">Order::Foreground</code>):</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌──────────────────────────────────────────────┐
│  Add folder connection                       │
│  ┌────────────────────────────────────────┐  │
│  │ ⌂ home ▸ djroyleo ▸ CODE ▸ gis-data    │  │ ← breadcrumb row: each
│  ├────────────────────────────────────────┤  │   segment is a button that
│  │  📁 boundaries                         │  │   jumps to that ancestor
│  │  📁 elevation          (single-click   │  │
│  │  📁 hydrology     ←     selects,       │  │ ← ScrollArea; folders only
│  │  📁 imagery             double-click   │  │   (files may show greyed
│  │                         descends)      │  │   for orientation)
│  ├────────────────────────────────────────┤  │
│  │ Selected: ~/CODE/gis-data/hydrology    │  │
│  │                     [Cancel] [Select]  │  │
│  └────────────────────────────────────────┘  │
└──────────────────────────────────────────────┘
</code></pre></div></div> <p>Rules:</p> <ul> <li>All listing goes through <code class="language-plaintext highlighter-rouge">CatalogCache::entries</code> / <code class="language-plaintext highlighter-rouge">list_dir</code> (rule 5 — the picker does <strong>zero</strong> direct <code class="language-plaintext highlighter-rouge">std::fs</code> calls). Folders only ⇒ filter for <code class="language-plaintext highlighter-rouge">CatalogEntry::Folder</code>.</li> <li>Breadcrumbs come from <code class="language-plaintext highlighter-rouge">Path::ancestors()</code> (reversed); each is a button setting <code class="language-plaintext highlighter-rouge">self.current</code>.</li> <li><strong>Select</strong> returns <code class="language-plaintext highlighter-rouge">Picked(selection.unwrap_or(current))</code> — i.e. with nothing highlighted, the button reads “Select current folder” and picks the directory being viewed. (Write it with <code class="language-plaintext highlighter-rouge">map_or</code>/<code class="language-plaintext highlighter-rouge">unwrap_or_else</code>, not <code class="language-plaintext highlighter-rouge">unwrap</code>.)</li> <li>Navigation into an unreadable directory sets <code class="language-plaintext highlighter-rouge">self.error</code> and stays put; show it as a red inline label, not a toast (the modal is focused context).</li> <li>Keyboard: Escape = cancel (<code class="language-plaintext highlighter-rouge">Modal</code> gives this for free), Enter = select.</li> </ul> <p>Ownership/flow: <code class="language-plaintext highlighter-rouge">DialogState</code> in <code class="language-plaintext highlighter-rouge">AegisApp</code> holds <code class="language-plaintext highlighter-rouge">Option&lt;Dialog&gt;</code>; <code class="language-plaintext highlighter-rouge">AegisApp::ui</code> calls the open dialog’s <code class="language-plaintext highlighter-rouge">ui()</code> after the panels; <code class="language-plaintext highlighter-rouge">Picked(path)</code> ⇒ push <code class="language-plaintext highlighter-rouge">FolderConnection::new(path)</code> into <code class="language-plaintext highlighter-rouge">catalog.connections</code> (dedupe: skip if an identical or ancestor connection exists — or allow duplicates and let the <code class="language-plaintext highlighter-rouge">id_salt(path)</code> keep the tree sane; recommend dedupe-on-exact-match only). Persist connections across runs — see §5.3.6, same mechanism as the workspace layout.</p> <h4 id="415-acceptance-checklist-for-41">4.1.5 Acceptance checklist for §4.1</h4> <ul class="task-list"> <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled"/>No button in the catalog pane; right-click anywhere in it (including below the tree) opens the menu.</li> <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled"/>Picker opens centered, navigates the real filesystem, survives permission-denied directories without panicking (lints make panics impossible; the <em>behavior</em> must also be graceful).</li> <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled"/>Added folder appears as a root; expanding is lazy; unreadable subfolder shows an inline error row; Refresh recovers after external changes.</li> <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled"/><code class="language-plaintext highlighter-rouge">cargo clippy --workspace --all-targets</code> is clean.</li> </ul> <hr/> <h3 id="42-io-drivers">4.2 IO drivers</h3> <p>The catalog is only as useful as the registry behind it. Ship drivers in this order (all in <code class="language-plaintext highlighter-rouge">aegis-io</code>, each a <code class="language-plaintext highlighter-rouge">driver/&lt;format&gt;.rs</code> module implementing <code class="language-plaintext highlighter-rouge">DatasetDriver</code>, registered in a new <code class="language-plaintext highlighter-rouge">pub fn default_registry() -&gt; DriverRegistry</code> that <code class="language-plaintext highlighter-rouge">aegis-app</code> calls at startup):</p> <ol> <li><strong>GeoJSON</strong> (<code class="language-plaintext highlighter-rouge">geojson</code> crate, pure Rust). <code class="language-plaintext highlighter-rouge">can_open</code>: extension <code class="language-plaintext highlighter-rouge">geojson</code>/ <code class="language-plaintext highlighter-rouge">json</code> (for <code class="language-plaintext highlighter-rouge">json</code>, sniff the first non-whitespace bytes for <code class="language-plaintext highlighter-rouge">{</code>, then parse lazily — or just claim <code class="language-plaintext highlighter-rouge">.geojson</code> only, simplest). <code class="language-plaintext highlighter-rouge">read</code>: the <code class="language-plaintext highlighter-rouge">geojson</code> crate converts to <code class="language-plaintext highlighter-rouge">geo_types::Geometry</code> directly (it has georust interop), properties map onto <code class="language-plaintext highlighter-rouge">AttributeValue</code> (Null/Bool/Number→Int-or-Float/String→Text); build the <code class="language-plaintext highlighter-rouge">Schema</code> by unioning the property keys of all features. CRS: GeoJSON is by spec WGS84 ⇒ <code class="language-plaintext highlighter-rouge">Crs::Epsg(4326)</code>. <code class="language-plaintext highlighter-rouge">write</code>: the reverse; refuse <code class="language-plaintext highlighter-rouge">Dataset::Raster</code> with <code class="language-plaintext highlighter-rouge">AegisError::InvalidData</code>.</li> <li><strong>Shapefile</strong> (<code class="language-plaintext highlighter-rouge">shapefile</code> crate). Brings the sidecar-suppression need (§4.1.1). DBF fields map cleanly onto <code class="language-plaintext highlighter-rouge">FieldKind</code>.</li> <li><strong>GeoTIFF</strong> (via <code class="language-plaintext highlighter-rouge">tiff</code>, or bite the GDAL bullet later) — first raster driver; unblocks raster rendering (§4.10.2).</li> <li><strong>GeoPackage</strong> (<code class="language-plaintext highlighter-rouge">rusqlite</code> + WKB parsing via <code class="language-plaintext highlighter-rouge">geozero</code>) — later; it’s the modern default and worth doing well.</li> </ol> <p>Testing: drivers are pure functions on paths — give each a <code class="language-plaintext highlighter-rouge">tests/</code> dir with tiny fixture files. This is the most unit-testable corner of the codebase; use it to establish the test conventions (§8.4).</p> <h3 id="43-adding-data-to-the-map">4.3 Adding data to the map</h3> <p>The missing link between catalog and contents. Flow, entirely via commands:</p> <ol> <li>Catalog row’s <strong>Add to map</strong> / double-click pushes <code class="language-plaintext highlighter-rouge">AppCommand::AddLayerFromPath(path)</code>.</li> <li><code class="language-plaintext highlighter-rouge">AegisApp::apply</code>: <ul> <li><code class="language-plaintext highlighter-rouge">self.drivers.read(&amp;path)</code> → <code class="language-plaintext highlighter-rouge">Dataset</code> (errors → toast, §6).</li> <li>Convert to a <code class="language-plaintext highlighter-rouge">Layer</code>: this is the caller’s job by design (rule 4). Add a small helper in <strong><code class="language-plaintext highlighter-rouge">aegis-app</code></strong> (e.g. <code class="language-plaintext highlighter-rouge">layer_from_dataset(name, dataset)</code>) that matches <code class="language-plaintext highlighter-rouge">Dataset::Vector/Raster</code> onto <code class="language-plaintext highlighter-rouge">Layer::vector</code>/ <code class="language-plaintext highlighter-rouge">Layer::raster</code> with <strong>default symbology from a rotating palette</strong> (deterministic sequence of pleasant colors — store a counter in <code class="language-plaintext highlighter-rouge">AegisApp</code>; the palette itself can live in the theme module §5.1.4). Name = file stem.</li> <li><code class="language-plaintext highlighter-rouge">self.project.add_layer(layer)</code> and optionally auto-zoom: push <code class="language-plaintext highlighter-rouge">ZoomTo</code> with the dataset’s bounds (§4.9).</li> </ul> </li> <li>Reading large files will eventually block the UI; when that day comes, move the read onto the job runner (§4.7.3) — the command indirection means nothing else changes.</li> </ol> <h3 id="44-contents-panel">4.4 Contents panel</h3> <p>Current state: checkbox + name + dead context menu. Build-out, in order:</p> <ol> <li><strong>Selection.</strong> Per <code class="language-plaintext highlighter-rouge">aegis-project</code>’s own doc, selection state belongs in the project crate, not the GUI. Add to <code class="language-plaintext highlighter-rouge">Project</code>: <code class="language-plaintext highlighter-rouge">pub active_layer: Option&lt;LayerId&gt;</code> (and later <code class="language-plaintext highlighter-rouge">selected_features: HashMap&lt;LayerId, Vec&lt;usize&gt;&gt;</code> for feature selection). Clicking a layer name selects it (<code class="language-plaintext highlighter-rouge">AppCommand::SelectLayer</code>); paint the row highlighted (<code class="language-plaintext highlighter-rouge">ui.visuals().selection</code>). The symbology pane (§4.5) and attribute table (§4.6) both key off <code class="language-plaintext highlighter-rouge">active_layer</code>.</li> <li><strong>Wire the context menu</strong> to real commands: <ul> <li><em>Open attribute table</em> → <code class="language-plaintext highlighter-rouge">OpenPanel(PanelKind::AttributeTable(id))</code></li> <li><em>Symbology</em> → <code class="language-plaintext highlighter-rouge">SelectLayer(id)</code> + <code class="language-plaintext highlighter-rouge">OpenPanel(PanelKind::Symbology)</code></li> <li><em>Zoom to layer</em> (new item) → <code class="language-plaintext highlighter-rouge">ZoomTo</code> with <code class="language-plaintext highlighter-rouge">layer.source.bounds()</code> (skip the item when bounds are <code class="language-plaintext highlighter-rouge">None</code>)</li> <li><em>Remove</em> (new item) → <code class="language-plaintext highlighter-rouge">RemoveLayer(id)</code></li> <li><em>Rename</em> (new item) → sets <code class="language-plaintext highlighter-rouge">renaming: Option&lt;LayerId&gt;</code> in panel state; that row renders a <code class="language-plaintext highlighter-rouge">TextEdit</code> until Enter/focus-loss commits.</li> </ul> </li> <li><strong>Reordering.</strong> Draw order = list order (bottom first), and the Contents panel should display <strong>top-of-stack first</strong> like every GIS, i.e. iterate <code class="language-plaintext highlighter-rouge">project.layers.iter().rev()</code> — do this flip in the panel only, never reorder the model for display. Implement drag-to-reorder with egui’s drag-and-drop (<code class="language-plaintext highlighter-rouge">ui.dnd_drag_source</code> / <code class="language-plaintext highlighter-rouge">dnd_drop_zone</code>, id = <code class="language-plaintext highlighter-rouge">LayerId</code>); fallback (or v1): ▲/▼ buttons on the selected row pushing <code class="language-plaintext highlighter-rouge">AppCommand::MoveLayer { id, delta: isize }</code>. Reordering by id keeps rule 6 intact.</li> <li><strong>Per-kind icons</strong> (point/line/polygon/raster glyph before the name) — a small symbology swatch drawn with <code class="language-plaintext highlighter-rouge">ui.painter()</code> (a 14×14 rect: fill + stroke from the layer’s <code class="language-plaintext highlighter-rouge">VectorSymbology</code>) doubles as a legend and a click-target for the symbology pane. This is the first place the app’s <em>own</em> visual identity shows up (§5).</li> </ol> <h3 id="45-symbology-pane">4.5 Symbology pane</h3> <p>A new panel (<code class="language-plaintext highlighter-rouge">panels/symbology.rs</code>) editing the <strong>active layer’s</strong> <code class="language-plaintext highlighter-rouge">Symbology</code> in place (live preview — the map redraws every frame anyway).</p> <ul> <li>Resolve <code class="language-plaintext highlighter-rouge">project.active_layer</code> → <code class="language-plaintext highlighter-rouge">project.layer_mut(id)</code>; if <code class="language-plaintext highlighter-rouge">None</code>, show a hint (“Select a layer in Contents”).</li> <li><code class="language-plaintext highlighter-rouge">Symbology::Vector</code>: color pickers for <code class="language-plaintext highlighter-rouge">stroke.color</code> and <code class="language-plaintext highlighter-rouge">fill.color</code>, <code class="language-plaintext highlighter-rouge">DragValue</code>/<code class="language-plaintext highlighter-rouge">Slider</code> for <code class="language-plaintext highlighter-rouge">stroke.width</code> (0.1–10 px) and <code class="language-plaintext highlighter-rouge">point_radius</code> (1–20 px).</li> <li><code class="language-plaintext highlighter-rouge">Symbology::Raster</code>: <code class="language-plaintext highlighter-rouge">opacity</code> slider 0.0–1.0.</li> <li> <p><strong>Color conversion:</strong> egui edits <code class="language-plaintext highlighter-rouge">egui::Color32</code>; the model stores <code class="language-plaintext highlighter-rouge">aegis_core::Color</code>. <code class="language-plaintext highlighter-rouge">render_egui/mod.rs</code> already has <code class="language-plaintext highlighter-rouge">to_color32</code>; add the inverse <code class="language-plaintext highlighter-rouge">from_color32</code> <strong>next to it</strong> (same module — it’s the designated egui↔core conversion spot) and pattern:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="k">mut</span> <span class="n">c</span> <span class="o">=</span> <span class="nf">to_color32</span><span class="p">(</span><span class="n">sym</span><span class="py">.stroke.color</span><span class="p">);</span>
<span class="k">if</span> <span class="n">ui</span><span class="nf">.color_edit_button_srgba</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="n">c</span><span class="p">)</span><span class="nf">.changed</span><span class="p">()</span> <span class="p">{</span>
    <span class="n">sym</span><span class="py">.stroke.color</span> <span class="o">=</span> <span class="nf">from_color32</span><span class="p">(</span><span class="n">c</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div> </div> </li> <li>When categorized/graduated renderers arrive, they extend <code class="language-plaintext highlighter-rouge">aegis-project::symbology</code> (per its doc comment), and this pane grows a renderer-type dropdown. Design the pane as a <code class="language-plaintext highlighter-rouge">match</code> from day one.</li> </ul> <h3 id="46-attribute-table">4.6 Attribute table</h3> <p>New panel kind (<code class="language-plaintext highlighter-rouge">panels/attribute_table.rs</code>), opened per layer, showing <code class="language-plaintext highlighter-rouge">VectorDataset.schema</code> as columns and <code class="language-plaintext highlighter-rouge">features</code> as rows.</p> <ul> <li>Use <code class="language-plaintext highlighter-rouge">egui_extras::TableBuilder</code> (new dep, §7) — it provides striped rows, resizable columns, and <strong>row virtualization</strong> (<code class="language-plaintext highlighter-rouge">body.rows(height, count, |row| ...)</code>), which matters the moment a real shapefile with 100k rows loads. Do not hand-roll with <code class="language-plaintext highlighter-rouge">Grid</code>.</li> <li>Cell text: match on <code class="language-plaintext highlighter-rouge">feature.attributes.get(&amp;field.name)</code> → <code class="language-plaintext highlighter-rouge">AttributeValue</code> display; missing key renders as <code class="language-plaintext highlighter-rouge">NULL</code>-styled weak text. (<code class="language-plaintext highlighter-rouge">.get()</code>, never indexing — lint.)</li> <li>Column order comes from <code class="language-plaintext highlighter-rouge">Schema</code> (a <code class="language-plaintext highlighter-rouge">Vec</code>, so it’s stable).</li> <li>Rows are addressed by index into <code class="language-plaintext highlighter-rouge">features</code> — acceptable <em>inside</em> one panel frame, but anything persistent (selection) should eventually use a feature id; note it as a TODO tied to the editing feature, don’t build it yet.</li> <li>Clicking a row → feature selection (<code class="language-plaintext highlighter-rouge">Project.selected_features</code>) → §4.10’s renderer draws selected features with a highlight color. This is the first cross-panel-to-map interaction; it exercises the whole command/project-state pipeline.</li> </ul> <h3 id="47-geoprocessing-toolbox-and-background-jobs">4.7 Geoprocessing toolbox and background jobs</h3> <h4 id="471-tools-in-aegis-geoprocessing">4.7.1 Tools (in <code class="language-plaintext highlighter-rouge">aegis-geoprocessing</code>)</h4> <p>Structure: <code class="language-plaintext highlighter-rouge">src/tools/vector/buffer.rs</code> etc., plus <code class="language-plaintext highlighter-rouge">pub fn default_registry() -&gt; ToolRegistry</code>. First tools, all backed by the <code class="language-plaintext highlighter-rouge">geo</code> crate (already a dependency — never reimplement computational geometry, per the crate doc):</p> <table> <thead> <tr> <th>Tool id</th> <th>geo backing</th> <th>Params (ParamSpec)</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">vector:buffer</code></td> <td><code class="language-plaintext highlighter-rouge">geo::Buffer</code></td> <td>input: VectorInput; distance: Number</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">vector:centroid</code></td> <td><code class="language-plaintext highlighter-rouge">geo::Centroid</code></td> <td>input: VectorInput</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">vector:convex_hull</code></td> <td><code class="language-plaintext highlighter-rouge">geo::ConvexHull</code></td> <td>input: VectorInput</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">vector:simplify</code></td> <td><code class="language-plaintext highlighter-rouge">geo::Simplify</code></td> <td>input: VectorInput; tolerance: Number{min:0}</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">vector:clip</code> / <code class="language-plaintext highlighter-rouge">union</code> / <code class="language-plaintext highlighter-rouge">difference</code></td> <td><code class="language-plaintext highlighter-rouge">geo::BooleanOps</code></td> <td>two VectorInputs</td> </tr> </tbody> </table> <p>Each <code class="language-plaintext highlighter-rouge">run</code> validates <code class="language-plaintext highlighter-rouge">args</code> (missing/ill-typed → <code class="language-plaintext highlighter-rouge">InvalidParameter</code> — the error type already exists for exactly this) and returns <code class="language-plaintext highlighter-rouge">ToolOutput { datasets }</code>.</p> <h4 id="472-the-auto-generated-tool-dialog-in-aegis-app">4.7.2 The auto-generated tool dialog (in <code class="language-plaintext highlighter-rouge">aegis-app</code>)</h4> <p>New <code class="language-plaintext highlighter-rouge">panels/toolbox.rs</code> (a panel: search box filtering <code class="language-plaintext highlighter-rouge">tools.iter()</code> by label, click → open dialog) and <code class="language-plaintext highlighter-rouge">dialogs/tool_dialog.rs</code>:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">struct</span> <span class="n">ToolDialog</span> <span class="p">{</span>
    <span class="n">tool_id</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">'static</span> <span class="nb">str</span><span class="p">,</span>
    <span class="n">values</span><span class="p">:</span> <span class="n">HashMap</span><span class="o">&lt;&amp;</span><span class="k">'static</span> <span class="nb">str</span><span class="p">,</span> <span class="n">ParamValueDraft</span><span class="o">&gt;</span><span class="p">,</span>  <span class="c1">// widget state per param</span>
<span class="p">}</span>
</code></pre></div></div> <p>Widget per <code class="language-plaintext highlighter-rouge">ParamKind</code> — this mapping <strong>is</strong> the “zero GUI edits per tool” promise, so keep it exhaustive and generic:</p> <table> <thead> <tr> <th>ParamKind</th> <th>Widget</th> <th>Draft → ParamValue</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">VectorInput</code></td> <td>ComboBox over <code class="language-plaintext highlighter-rouge">project.layers</code> filtered to <code class="language-plaintext highlighter-rouge">LayerSource::Vector</code>, storing <code class="language-plaintext highlighter-rouge">LayerId</code></td> <td>resolve id → <strong>clone</strong> the dataset at run time</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">RasterInput</code></td> <td>same, filtered to raster</td> <td>same</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">Number{min,max}</code></td> <td><code class="language-plaintext highlighter-rouge">DragValue</code> clamped to the bounds</td> <td><code class="language-plaintext highlighter-rouge">ParamValue::Number</code></td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">Text</code></td> <td><code class="language-plaintext highlighter-rouge">TextEdit::singleline</code></td> <td><code class="language-plaintext highlighter-rouge">Text</code></td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">Bool</code></td> <td><code class="language-plaintext highlighter-rouge">Checkbox</code></td> <td><code class="language-plaintext highlighter-rouge">Bool</code></td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">Path</code></td> <td>read-only text + “…” button opening the folder/file picker (§4.1.4 — generalize the picker with a <code class="language-plaintext highlighter-rouge">PickTarget::{Folder, File}</code> mode)</td> <td><code class="language-plaintext highlighter-rouge">Path</code></td> </tr> </tbody> </table> <p>“Run” validates required params client-side (disable the button + show which are missing), builds <code class="language-plaintext highlighter-rouge">ToolArgs</code>, pushes <code class="language-plaintext highlighter-rouge">AppCommand::RunTool</code>.</p> <p>Datasets are <strong>cloned</strong> into <code class="language-plaintext highlighter-rouge">ToolArgs</code> — that’s the current design (<code class="language-plaintext highlighter-rouge">ParamValue::Vector(VectorDataset)</code> holds owned data) and it’s what makes background execution trivially safe. When datasets get big enough to hurt, change <code class="language-plaintext highlighter-rouge">ParamValue</code> to hold <code class="language-plaintext highlighter-rouge">Arc&lt;VectorDataset&gt;</code> — one crate, mechanical change; don’t pre-optimize now.</p> <h4 id="473-the-job-runner-in-aegis-geoprocessing--not-the-app">4.7.3 The job runner (in <code class="language-plaintext highlighter-rouge">aegis-geoprocessing</code> — not the app)</h4> <p>The crate doc already reserves this: tools must never block the UI thread.</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// aegis-geoprocessing/src/jobs.rs</span>
<span class="k">pub</span> <span class="k">struct</span> <span class="n">JobRunner</span> <span class="p">{</span> <span class="cm">/* mpsc channels, JoinHandles */</span> <span class="p">}</span>
<span class="k">pub</span> <span class="k">struct</span> <span class="nf">JobId</span><span class="p">(</span><span class="nb">u64</span><span class="p">);</span>
<span class="k">pub</span> <span class="k">enum</span> <span class="n">JobStatus</span> <span class="p">{</span> <span class="n">Running</span><span class="p">,</span> <span class="nf">Finished</span><span class="p">(</span><span class="nb">Result</span><span class="o">&lt;</span><span class="n">ToolOutput</span><span class="p">,</span> <span class="n">AegisError</span><span class="o">&gt;</span><span class="p">)</span> <span class="p">}</span>

<span class="k">impl</span> <span class="n">JobRunner</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">submit</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">registry</span><span class="p">:</span> <span class="o">&amp;</span><span class="nb">Arc</span><span class="o">&lt;</span><span class="n">ToolRegistry</span><span class="o">&gt;</span><span class="p">,</span>
                  <span class="n">tool_id</span><span class="p">:</span> <span class="o">&amp;</span><span class="nb">str</span><span class="p">,</span> <span class="n">args</span><span class="p">:</span> <span class="n">ToolArgs</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="n">JobId</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>
    <span class="cd">/// Non-blocking; the GUI calls this once per frame.</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">poll</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="p">(</span><span class="n">JobId</span><span class="p">,</span> <span class="n">JobLabel</span><span class="p">,</span> <span class="nb">Result</span><span class="o">&lt;</span><span class="n">ToolOutput</span><span class="p">,</span> <span class="n">AegisError</span><span class="o">&gt;</span><span class="p">)</span><span class="o">&gt;</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <ul> <li><code class="language-plaintext highlighter-rouge">std::thread::spawn</code> + <code class="language-plaintext highlighter-rouge">std::sync::mpsc</code> — no async runtime needed. <code class="language-plaintext highlighter-rouge">GeoprocessingTool</code> is <code class="language-plaintext highlighter-rouge">Send + Sync</code> and <code class="language-plaintext highlighter-rouge">ToolArgs</code> is owned data, so this compiles without drama. Move a clone of the <code class="language-plaintext highlighter-rouge">Arc&lt;ToolRegistry&gt;</code> into the thread.</li> <li><code class="language-plaintext highlighter-rouge">AegisApp</code> owns a <code class="language-plaintext highlighter-rouge">JobRunner</code>, calls <code class="language-plaintext highlighter-rouge">poll()</code> at the top of <code class="language-plaintext highlighter-rouge">ui</code>, and for each finished job converts output datasets → layers (same palette helper as §4.3), toast on error. While jobs run, show a spinner row in a status bar (§5.4) or the toolbox panel.</li> <li>If a job is running, <code class="language-plaintext highlighter-rouge">ctx.request_repaint_after(Duration::from_millis(100))</code> so results appear promptly even when the user isn’t moving the mouse.</li> <li>Progress reporting: later, extend <code class="language-plaintext highlighter-rouge">submit</code> to hand the tool a <code class="language-plaintext highlighter-rouge">ProgressSink</code> (a channel-backed callback param on a new optional trait method <code class="language-plaintext highlighter-rouge">run_with_progress</code>). Design decision recorded here so <code class="language-plaintext highlighter-rouge">run</code>’s signature doesn’t churn twice.</li> </ul> <h3 id="48-project-saveload">4.8 Project save/load</h3> <p>Give every data crate an <strong>optional, feature-gated</strong> serde dependency so the core stays pure by default:</p> <div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># workspace deps</span>
<span class="n">serde</span> <span class="o">=</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">version</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"1"</span><span class="p">,</span><span class="w"> </span><span class="n">features</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="p">[</span><span class="s">"derive"</span><span class="p">]</span><span class="w"> </span><span class="p">}</span>
<span class="n">ron</span>   <span class="o">=</span><span class="w"> </span><span class="s">"0.11"</span>          <span class="c"># or serde_json; RON is friendlier to hand-inspection</span>

<span class="c"># in each data crate's Cargo.toml</span>
<span class="k">[</span><span class="n">features</span><span class="k">]</span>
<span class="n">serde</span> <span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="s">"dep:serde"</span><span class="p">,</span> <span class="s">"geo-types/serde"</span><span class="p">]</span>      <span class="c"># geo-types has serde support</span>
</code></pre></div></div> <p>Derive <code class="language-plaintext highlighter-rouge">Serialize/Deserialize</code> (behind <code class="language-plaintext highlighter-rouge">#[cfg_attr(feature = "serde", ...)]</code>) on: <code class="language-plaintext highlighter-rouge">Color</code>, <code class="language-plaintext highlighter-rouge">LineStyle</code>, <code class="language-plaintext highlighter-rouge">FillStyle</code>, <code class="language-plaintext highlighter-rouge">Crs</code> (core); dataset types (vector, raster); <code class="language-plaintext highlighter-rouge">Layer</code>, <code class="language-plaintext highlighter-rouge">LayerSource</code>, <code class="language-plaintext highlighter-rouge">Symbology</code>, <code class="language-plaintext highlighter-rouge">Project</code> (project).</p> <p>Two deliberate wrinkles:</p> <ol> <li><strong><code class="language-plaintext highlighter-rouge">LayerId</code> must not round-trip.</strong> It’s process-unique (<code class="language-plaintext highlighter-rouge">AtomicU64</code>); persisting raw ids would collide on the next run. Skip the field (<code class="language-plaintext highlighter-rouge">#[serde(skip, default = "LayerId::next")]</code>-style) so loading allocates fresh ids. Anything that referenced old ids is gone after a load anyway (fresh app state).</li> <li><strong>Path-based vs embedded layers.</strong> Real GIS projects store <em>references</em> to data files, not copies. Add provenance to <code class="language-plaintext highlighter-rouge">Layer</code>: <code class="language-plaintext highlighter-rouge">pub source_path: Option&lt;PathBuf&gt;</code> (set by §4.3 when a layer comes from a file; <code class="language-plaintext highlighter-rouge">None</code> for in-memory/tool outputs). Save writes the path for file-backed layers and embeds the dataset otherwise. On load, re-read file-backed layers through the <code class="language-plaintext highlighter-rouge">DriverRegistry</code>. <strong>Keep the crate boundary:</strong> <code class="language-plaintext highlighter-rouge">aegis-project</code> defines the serializable schema and can save/load the embedded case; the re-reading of paths is orchestrated in <code class="language-plaintext highlighter-rouge">aegis-app</code> (which already owns both the project and the registry). Do <em>not</em> make <code class="language-plaintext highlighter-rouge">aegis-io</code> depend on <code class="language-plaintext highlighter-rouge">aegis-project</code> — drivers speak datasets (rule 4), and project-file orchestration is app-level composition. Missing files at load → layer kept as a “broken link” entry (name + path, no source) with a toast, mirroring how ArcGIS/QGIS mark red-! layers. This requires <code class="language-plaintext highlighter-rouge">LayerSource::Broken { path: PathBuf }</code> — a variant addition exactly as the <code class="language-plaintext highlighter-rouge">LayerSource</code> doc prescribes (renderer arm = draw nothing).</li> <li>File format: <code class="language-plaintext highlighter-rouge">.aegis</code> extension, RON or JSON body, top-level struct <code class="language-plaintext highlighter-rouge">ProjectFile { version: u32, project: Project }</code> — version it from day one.</li> </ol> <p>Menu wiring (§5.4): File ▸ New / Open… / Save / Save As… — Open/Save use the picker (§4.1.4) in file mode. Track <code class="language-plaintext highlighter-rouge">dirty: bool</code> (set in <code class="language-plaintext highlighter-rouge">apply</code>) and the current path in <code class="language-plaintext highlighter-rouge">AegisApp</code>; confirm-on-close comes later.</p> <h3 id="49-map-navigation-extras">4.9 Map navigation extras</h3> <ol> <li> <p><strong><code class="language-plaintext highlighter-rouge">MapCamera::fit_rect</code></strong> — in <code class="language-plaintext highlighter-rouge">aegis-render/src/camera.rs</code> (pure f64 math, unit-test it there):</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">/// Center on `rect` and zoom so it fits inside `viewport` with</span>
<span class="cd">/// `padding` (a fraction, e.g. 0.05) of margin on each side.</span>
<span class="cd">/// Degenerate rects (a single point) only re-center.</span>
<span class="k">pub</span> <span class="k">fn</span> <span class="nf">fit_rect</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">rect</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">Rect</span><span class="o">&lt;</span><span class="nb">f64</span><span class="o">&gt;</span><span class="p">,</span> <span class="n">viewport</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">ScreenRect</span><span class="p">,</span>
                <span class="n">padding</span><span class="p">:</span> <span class="nb">f64</span><span class="p">)</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>
</code></pre></div> </div> <p><code class="language-plaintext highlighter-rouge">pixels_per_unit = min(vw / rw, vh / rh) * (1.0 - padding)</code>, clamped to <code class="language-plaintext highlighter-rouge">MIN_ZOOM..=MAX_ZOOM</code>; guard <code class="language-plaintext highlighter-rouge">rw</code>/<code class="language-plaintext highlighter-rouge">rh</code> of 0 (point layers!) by only setting <code class="language-plaintext highlighter-rouge">center</code>. This unlocks: Zoom to layer (§4.4), zoom to selection, View ▸ Zoom to full extent (<code class="language-plaintext highlighter-rouge">project.bounds()</code> already exists for exactly this), and auto-zoom on first added layer (§4.3).</p> </li> <li><strong>Status bar readout</strong> (§5.4): the map view already knows <code class="language-plaintext highlighter-rouge">response.hover_pos()</code>; convert with <code class="language-plaintext highlighter-rouge">camera.screen_to_world</code> and push the coordinate up (return a small <code class="language-plaintext highlighter-rouge">MapViewOutput { hover_world: Option&lt;Coord&gt; }</code> from <code class="language-plaintext highlighter-rouge">map_view::show</code> rather than writing to globals).</li> <li><strong>Tool modes.</strong> Panning/zooming is currently unconditional. Introduce <code class="language-plaintext highlighter-rouge">enum MapTool { Pan, Identify, Measure, /* later: Edit */ }</code> in app state with a small toolbar strip; <code class="language-plaintext highlighter-rouge">map_view::show</code> matches on it: <code class="language-plaintext highlighter-rouge">Identify</code> consumes clicks → hit-test features (hit-testing math = world-space point-in-polygon/distance-to-line via <code class="language-plaintext highlighter-rouge">geo</code>; put the helper in <code class="language-plaintext highlighter-rouge">aegis-render</code> or a query module in <code class="language-plaintext highlighter-rouge">aegis-vector</code> — it takes camera + tolerance-in-pixels, so <code class="language-plaintext highlighter-rouge">aegis-render</code> is the better home) → select feature + open a popup of its attributes.</li> </ol> <h3 id="410-rendering-roadmap">4.10 Rendering roadmap</h3> <p>Keep rule 8 sacred: all of this happens behind <code class="language-plaintext highlighter-rouge">render_egui::paint</code> (CPU) or <code class="language-plaintext highlighter-rouge">MapRenderer</code> (GPU); panels never change.</p> <h4 id="4101-polygon-fills-near-term-cpu">4.10.1 Polygon fills (near-term, CPU)</h4> <p>The lake polygon is outline-only because egui fills only convex polygons (<code class="language-plaintext highlighter-rouge">vector.rs</code> comment). Fix:</p> <ul> <li>Triangulation lives in <strong><code class="language-plaintext highlighter-rouge">aegis-render</code></strong> (toolkit-free geometry): <code class="language-plaintext highlighter-rouge">pub fn triangulate(polygon: &amp;Polygon&lt;f64&gt;) -&gt; Vec&lt;[Coord&lt;f64&gt;; 3]&gt;</code> (or indices + vertices) using the <code class="language-plaintext highlighter-rouge">earcutr</code> crate — it handles holes.</li> <li><code class="language-plaintext highlighter-rouge">render_egui/vector.rs::paint_polygon</code> builds an <code class="language-plaintext highlighter-rouge">egui::Mesh</code> from the triangles (<code class="language-plaintext highlighter-rouge">to_pos2</code> at the boundary, <code class="language-plaintext highlighter-rouge">to_color32(symbology.fill.color)</code>), then strokes the rings as today. Skip the mesh when <code class="language-plaintext highlighter-rouge">fill.color.a == 0</code>.</li> <li>Re-triangulating every frame will be fine for dev data and wrong for real data. When it hurts, add a cache keyed by <code class="language-plaintext highlighter-rouge">(LayerId, feature index)</code> invalidated on data change — which needs a <code class="language-plaintext highlighter-rouge">generation: u64</code> counter on <code class="language-plaintext highlighter-rouge">VectorDataset</code> bumped by any mutation. Note this now, build it when needed; the cache lives in <code class="language-plaintext highlighter-rouge">aegis-app</code> beside the texture cache below.</li> </ul> <h4 id="4102-raster-rendering-cpu-after-the-first-raster-driver">4.10.2 Raster rendering (CPU, after the first raster driver)</h4> <p><code class="language-plaintext highlighter-rouge">render_egui/raster.rs</code> is a stub with the seam ready. Implementation:</p> <ul> <li>Convert bands → <code class="language-plaintext highlighter-rouge">egui::ColorImage</code> (single band: grayscale or colormap through a stretch min/max; three bands: RGB), honoring <code class="language-plaintext highlighter-rouge">nodata</code> as transparent. Band-selection/stretch parameters belong on <code class="language-plaintext highlighter-rouge">RasterSymbology</code> (extend it in <code class="language-plaintext highlighter-rouge">aegis-project</code> — its doc lists exactly this as future work).</li> <li>Upload once via <code class="language-plaintext highlighter-rouge">ctx.load_texture</code>, hold <code class="language-plaintext highlighter-rouge">TextureHandle</code>s in a <code class="language-plaintext highlighter-rouge">TextureCache</code> (new struct in <code class="language-plaintext highlighter-rouge">render_egui/</code>, owned by <code class="language-plaintext highlighter-rouge">AegisApp</code>, passed into <code class="language-plaintext highlighter-rouge">paint</code> — <code class="language-plaintext highlighter-rouge">paint</code>’s signature grows a <code class="language-plaintext highlighter-rouge">&amp;mut RenderCaches</code> parameter; this is expected and stays inside the seam).</li> <li>Draw with <code class="language-plaintext highlighter-rouge">painter.image(...)</code> mapping <code class="language-plaintext highlighter-rouge">dataset.bounds()</code> corners through <code class="language-plaintext highlighter-rouge">camera.world_to_screen</code> (+ <code class="language-plaintext highlighter-rouge">to_pos2</code>); tint alpha by <code class="language-plaintext highlighter-rouge">opacity</code>.</li> <li>Rebuild the texture only when symbology or data changes (generation counter again), never per frame.</li> </ul> <h4 id="4103-the-wgpu-pipeline-the-real-renderer">4.10.3 The wgpu pipeline (the real renderer)</h4> <p>When datasets outgrow the CPU path (typically: first real shapefile with</p> <blockquote> <p>100k features), build the pipeline <strong>in <code class="language-plaintext highlighter-rouge">aegis-render</code></strong> exactly as its doc comment plans:</p> </blockquote> <ol> <li>Enable eframe’s <code class="language-plaintext highlighter-rouge">wgpu</code> feature (workspace <code class="language-plaintext highlighter-rouge">Cargo.toml</code>: <code class="language-plaintext highlighter-rouge">eframe = { version = "0.35", features = ["wgpu"] }</code>) and add <code class="language-plaintext highlighter-rouge">egui_wgpu</code> to <code class="language-plaintext highlighter-rouge">aegis-app</code>; add <code class="language-plaintext highlighter-rouge">wgpu</code> to <code class="language-plaintext highlighter-rouge">aegis-render</code>.</li> <li>In <code class="language-plaintext highlighter-rouge">aegis-render</code>, implement <code class="language-plaintext highlighter-rouge">MapRenderer</code> (the trait is already there): <code class="language-plaintext highlighter-rouge">prepare</code> tessellates layers into per-layer vertex/index buffers (reusing §4.10.1’s triangulation; lines become quad strips with width in a shader), <code class="language-plaintext highlighter-rouge">render</code> sets the camera as a uniform (a 3×3/4×4 matrix built from <code class="language-plaintext highlighter-rouge">MapCamera</code> — add <code class="language-plaintext highlighter-rouge">MapCamera::to_matrix(viewport)</code>) and draws. f64→f32 for the GPU happens <em>here</em>: subtract a tile/layer origin first so f32 precision holds (the standard RTC — relative-to-center — trick); this becomes the second sanctioned narrowing point, documented like <code class="language-plaintext highlighter-rouge">to_pos2</code>.</li> <li>In <code class="language-plaintext highlighter-rouge">aegis-app</code>, <code class="language-plaintext highlighter-rouge">render_egui</code> shrinks to an <code class="language-plaintext highlighter-rouge">egui_wgpu::Callback</code> adapter that hands the <code class="language-plaintext highlighter-rouge">MapRenderer</code> the viewport — panels and <code class="language-plaintext highlighter-rouge">map_view</code> don’t change at all (rule 8 pays off here).</li> <li>Then: R-tree culling and hit-testing (<code class="language-plaintext highlighter-rouge">rstar</code> crate, index stored beside the dataset), raster tiles as textures, feature-selection highlighting as a per-vertex flag or second draw pass.</li> </ol> <p>Don’t start here. The CPU path with culling (already implemented) will carry development for a long time, and every earlier section works identically on both paths.</p> <hr/> <h2 id="5-gui-ownership">5. GUI ownership</h2> <h3 id="51-theming-full-control-of-the-look">5.1 Theming: full control of the look</h3> <p>Everything egui draws is controlled by <code class="language-plaintext highlighter-rouge">egui::Style</code> (spacing, interaction) and <code class="language-plaintext highlighter-rouge">egui::Visuals</code> (colors, strokes, rounding, shadows, fonts via <code class="language-plaintext highlighter-rouge">FontDefinitions</code>). Owning these completely is how AeGIS stops looking like “default egui” — before any layout changes.</p> <h4 id="511-create-cratesaegis-appsrcthemers">5.1.1 Create <code class="language-plaintext highlighter-rouge">crates/aegis-app/src/theme.rs</code></h4> <p>One module, one entry point, called <strong>once</strong> at startup — which requires using the <code class="language-plaintext highlighter-rouge">CreationContext</code> currently ignored in <code class="language-plaintext highlighter-rouge">main.rs</code> (<code class="language-plaintext highlighter-rouge">Box::new(|_cc| ...)</code> → <code class="language-plaintext highlighter-rouge">Box::new(|cc| { theme::apply(&amp;cc.egui_ctx); ... })</code>):</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">fn</span> <span class="nf">apply</span><span class="p">(</span><span class="n">ctx</span><span class="p">:</span> <span class="o">&amp;</span><span class="nn">egui</span><span class="p">::</span><span class="n">Context</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">ctx</span><span class="nf">.set_fonts</span><span class="p">(</span><span class="nf">font_definitions</span><span class="p">());</span>
    <span class="n">ctx</span><span class="nf">.set_style</span><span class="p">(</span><span class="nf">style</span><span class="p">());</span>          <span class="c1">// or set per-theme, see below</span>
<span class="p">}</span>
</code></pre></div></div> <p>Define the palette as named constants so every hard-coded color in the app dies (<code class="language-plaintext highlighter-rouge">egui::Color32::WHITE</code> in <code class="language-plaintext highlighter-rouge">app.rs</code>’s map background is the first casualty — replace with <code class="language-plaintext highlighter-rouge">theme::MAP_BACKGROUND</code> or, better, a per-map-view setting later):</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">mod</span> <span class="n">palette</span> <span class="p">{</span>
    <span class="k">use</span> <span class="nn">eframe</span><span class="p">::</span><span class="nn">egui</span><span class="p">::</span><span class="n">Color32</span><span class="p">;</span>
    <span class="k">pub</span> <span class="k">const</span> <span class="n">BG_CANVAS</span><span class="p">:</span> <span class="n">Color32</span> <span class="o">=</span> <span class="o">...</span><span class="p">;</span>   <span class="c1">// artboard background</span>
    <span class="k">pub</span> <span class="k">const</span> <span class="n">BG_PANEL</span><span class="p">:</span> <span class="n">Color32</span> <span class="o">=</span> <span class="o">...</span><span class="p">;</span>
    <span class="k">pub</span> <span class="k">const</span> <span class="n">ACCENT</span><span class="p">:</span> <span class="n">Color32</span> <span class="o">=</span> <span class="o">...</span><span class="p">;</span>      <span class="c1">// the one AeGIS brand color</span>
    <span class="k">pub</span> <span class="k">const</span> <span class="n">TEXT_PRIMARY</span><span class="p">:</span> <span class="n">Color32</span> <span class="o">=</span> <span class="o">...</span><span class="p">;</span>
    <span class="k">pub</span> <span class="k">const</span> <span class="n">TEXT_WEAK</span><span class="p">:</span> <span class="n">Color32</span> <span class="o">=</span> <span class="o">...</span><span class="p">;</span>
    <span class="c1">// ...</span>
<span class="p">}</span>
</code></pre></div></div> <h4 id="512-what-to-set-the-complete-checklist">5.1.2 What to set (the complete checklist)</h4> <p>Work through <code class="language-plaintext highlighter-rouge">egui::Visuals</code> field by field — every one left default is a place the app still looks like egui:</p> <ul> <li><code class="language-plaintext highlighter-rouge">visuals.widgets.{noninteractive, inactive, hovered, active, open}</code>: each a <code class="language-plaintext highlighter-rouge">WidgetVisuals</code> with <code class="language-plaintext highlighter-rouge">bg_fill</code>, <code class="language-plaintext highlighter-rouge">weak_bg_fill</code>, <code class="language-plaintext highlighter-rouge">bg_stroke</code>, <code class="language-plaintext highlighter-rouge">fg_stroke</code>, <code class="language-plaintext highlighter-rouge">corner_radius</code>, <code class="language-plaintext highlighter-rouge">expansion</code>. This quintet <em>is</em> the look of every button, header, and slider. Decide hover/active behavior once (e.g. hover = accent-tinted stroke, active = filled accent) and the whole app follows.</li> <li><code class="language-plaintext highlighter-rouge">visuals.selection</code> (used by text selection <em>and</em> selected rows — your Contents selection in §4.4 inherits this).</li> <li><code class="language-plaintext highlighter-rouge">visuals.window_fill</code>, <code class="language-plaintext highlighter-rouge">window_stroke</code>, <code class="language-plaintext highlighter-rouge">window_shadow</code>, <code class="language-plaintext highlighter-rouge">window_corner_radius</code> — these style <code class="language-plaintext highlighter-rouge">egui::Window</code>, i.e. <strong>the artboard panels</strong> (§5.3). A distinctive shadow + radius here does more for a unique identity than anything else.</li> <li><code class="language-plaintext highlighter-rouge">visuals.panel_fill</code> (docked panels), <code class="language-plaintext highlighter-rouge">extreme_bg_color</code> (text edits, scroll areas), <code class="language-plaintext highlighter-rouge">faint_bg_color</code> (striped table rows — §4.6), <code class="language-plaintext highlighter-rouge">hyperlink_color</code>, <code class="language-plaintext highlighter-rouge">error_fg_color</code>/<code class="language-plaintext highlighter-rouge">warn_fg_color</code> (§6 toasts and catalog error rows).</li> <li><code class="language-plaintext highlighter-rouge">style.spacing</code>: <code class="language-plaintext highlighter-rouge">item_spacing</code>, <code class="language-plaintext highlighter-rouge">button_padding</code>, <code class="language-plaintext highlighter-rouge">indent</code> (tree depth in the catalog!), <code class="language-plaintext highlighter-rouge">interact_size</code>, <code class="language-plaintext highlighter-rouge">scroll</code> (bar width/rounding).</li> <li><code class="language-plaintext highlighter-rouge">style.interaction</code>: <code class="language-plaintext highlighter-rouge">resize_grab_radius_side/corner</code> — matters a lot for floating panels.</li> <li>Light/dark: egui has per-theme styles (<code class="language-plaintext highlighter-rouge">ctx.set_style_of(egui::Theme::Dark, ...)</code> and a <code class="language-plaintext highlighter-rouge">ThemePreference</code>). Define <em>both</em> AeGIS light and AeGIS dark from the same palette module, wire a toggle into View (§5.4). Check exact API names against egui 0.35 docs — this area changed across recent versions.</li> </ul> <h4 id="513-fonts">5.1.3 Fonts</h4> <p>Typography is half the identity. Bundle fonts (license-permitting — e.g. Inter for UI + JetBrains Mono for coordinates/attribute values):</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">font_definitions</span><span class="p">()</span> <span class="k">-&gt;</span> <span class="nn">egui</span><span class="p">::</span><span class="n">FontDefinitions</span> <span class="p">{</span>
    <span class="k">let</span> <span class="k">mut</span> <span class="n">fonts</span> <span class="o">=</span> <span class="nn">egui</span><span class="p">::</span><span class="nn">FontDefinitions</span><span class="p">::</span><span class="nf">default</span><span class="p">();</span>
    <span class="n">fonts</span><span class="py">.font_data</span><span class="nf">.insert</span><span class="p">(</span><span class="s">"inter"</span><span class="nf">.into</span><span class="p">(),</span>
        <span class="nn">egui</span><span class="p">::</span><span class="nn">FontData</span><span class="p">::</span><span class="nf">from_static</span><span class="p">(</span><span class="nd">include_bytes!</span><span class="p">(</span><span class="s">"../assets/Inter-Regular.ttf"</span><span class="p">))</span><span class="nf">.into</span><span class="p">());</span>
    <span class="c1">// prepend to the Proportional family so it wins, keep egui's fallbacks</span>
    <span class="c1">// for glyph coverage; repeat for Monospace.</span>
    <span class="o">...</span>
<span class="p">}</span>
</code></pre></div></div> <p>Then map <code class="language-plaintext highlighter-rouge">TextStyle</code>s (<code class="language-plaintext highlighter-rouge">style.text_styles</code>): pick a deliberate scale (e.g. Small 11, Body 13, Button 13, Heading 17) instead of egui’s defaults. Panel titles in the artboard can use a custom <code class="language-plaintext highlighter-rouge">FontId</code> directly.</p> <h4 id="514-the-symbology-palette">5.1.4 The symbology palette</h4> <p>The rotating default-layer-color palette (§4.3) also lives in <code class="language-plaintext highlighter-rouge">theme.rs</code>, but expressed as <code class="language-plaintext highlighter-rouge">aegis_core::Color</code> (it flows into the model, not the chrome). Keep chrome palette (<code class="language-plaintext highlighter-rouge">Color32</code>) and data palette (<code class="language-plaintext highlighter-rouge">aegis_core::Color</code>) as two separate lists — they serve different masters (UI consistency vs. cartographic distinguishability).</p> <h3 id="52-custom-widgets-and-window-chrome">5.2 Custom widgets and window chrome</h3> <p>Two escalation levels beyond Style/Visuals, use as needed:</p> <ol> <li><strong>Custom widgets</strong> — for anything egui’s stock widgets can’t express (the layer swatch in §4.4, a compass/scale indicator on the map, panel tab handles in §5.3). Pattern: <code class="language-plaintext highlighter-rouge">ui.allocate_exact_size(size, sense)</code> → inspect <code class="language-plaintext highlighter-rouge">response</code> → draw with <code class="language-plaintext highlighter-rouge">ui.painter()</code> using palette constants. Put shared ones in <code class="language-plaintext highlighter-rouge">crates/aegis-app/src/widgets/</code>.</li> <li><strong>Owning the OS window frame</strong> — the final step of “complete control”: <code class="language-plaintext highlighter-rouge">NativeOptions { viewport: ViewportBuilder::default().with_decorations(false), .. }</code> removes the native title bar; AeGIS then draws its own top bar (menu + window title + min/max/close buttons) and implements dragging via <code class="language-plaintext highlighter-rouge">ui.interact(...)</code> + <code class="language-plaintext highlighter-rouge">ViewportCommand::StartDrag</code>, maximize/close via <code class="language-plaintext highlighter-rouge">ViewportCommand::Maximized</code>/<code class="language-plaintext highlighter-rouge">Close</code>. Do this <strong>only after</strong> the theme and artboard are in place — undecorated windows must also handle edge-resize (<code class="language-plaintext highlighter-rouge">ViewportCommand::BeginResize</code>) and it’s fiddly; it’s polish, not foundation.</li> </ol> <h3 id="53-the-artboard-workspace">5.3 The artboard workspace</h3> <p>The target: panels (Contents, Catalog, Symbology, Toolbox, Attribute tables, <strong>and the map views themselves</strong>) are floating, movable, collapsible, resizable cards on a large canvas. Fixed chrome: the top menu bar (and status bar, §5.4).</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌──────────────────────────────────────────────────────────────────────┐
│ File  Edit  View                                    (fixed menu bar) │
├──────────────────────────────────────────────────────────────────────┤
│ · · · · · · · · · · · · · · (artboard canvas) · · · · · · · · · · · ·│
│ ·  ┌─ Contents ────── ▾ ─┐        ┌─ Map: Main ───────────── ▾ ─┐  · │
│ ·  │ ☑ Stations           │        │                              │ ·│
│ ·  │ ☑ River              │        │        (map renders          │ ·│
│ ·  │ ☑ Lake               │        │         here, §4.10)         │ ·│
│ ·  └───────────────────── ┘        │                              │ ·│
│ ·                                  └──────────────────────────────┘ ·│
│ ·  ┌─ Catalog ──────── ▾ ─┐              ┌─ Map: Inset ──── ▾ ─┐   · │
│ ·  │ ▸ ~/CODE/gis-data     │              │   (second map,       │  ·│
│ ·  │ ▸ ~/fieldwork         │              │    own camera)       │  ·│
│ ·  └───────────────────────┘              └──────────────────────┘  ·│
│ · · · right-click canvas: "Add panel ▸", "Reset layout" · · · · · · ·│
├──────────────────────────────────────────────────────────────────────┤
│ 512334.21, 4281002.77   EPSG:—    2 jobs ⟳        (fixed status bar) │
└──────────────────────────────────────────────────────────────────────┘
</code></pre></div></div> <h4 id="531-strategy-eguiwindow-first-custom-chrome-second">5.3.1 Strategy: <code class="language-plaintext highlighter-rouge">egui::Window</code> first, custom chrome second</h4> <p><code class="language-plaintext highlighter-rouge">egui::Window</code> <strong>is</strong> a floating, draggable, collapsible, resizable panel — exactly the artboard card, with z-order/click-to-front handled by egui’s <code class="language-plaintext highlighter-rouge">Area</code> system for free. So the migration is:</p> <ul> <li><strong>Stage 1 (functional artboard):</strong> every panel content function (§3.3) rendered inside an <code class="language-plaintext highlighter-rouge">egui::Window</code>, heavily restyled via §5.1’s <code class="language-plaintext highlighter-rouge">window_*</code> visuals. You get move/collapse/resize/z-order/persistence hooks immediately, and ship a working artboard in days.</li> <li><strong>Stage 2 (unique chrome):</strong> replace <code class="language-plaintext highlighter-rouge">egui::Window</code> with your own container built on <code class="language-plaintext highlighter-rouge">egui::Area</code> (which supplies positioning/dragging/ z-order) + hand-drawn title bar, collapse animation (<code class="language-plaintext highlighter-rouge">ctx.animate_bool</code>), and resize handles. Do this only if Stage 1’s restyled windows still don’t feel “AeGIS enough” — you may be surprised how far <code class="language-plaintext highlighter-rouge">window_fill</code>/<code class="language-plaintext highlighter-rouge">shadow</code>/<code class="language-plaintext highlighter-rouge">corner_radius</code>/custom title-bar-text get you. The workspace model below is identical either way, so nothing is wasted.</li> </ul> <h4 id="532-the-workspace-model-in-aegis-app-new-workspacers">5.3.2 The workspace model (in <code class="language-plaintext highlighter-rouge">aegis-app</code>, new <code class="language-plaintext highlighter-rouge">workspace.rs</code>)</h4> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">enum</span> <span class="n">PanelKind</span> <span class="p">{</span>                      <span class="c1">// every floatable thing</span>
    <span class="n">Contents</span><span class="p">,</span>
    <span class="n">Catalog</span><span class="p">,</span>
    <span class="n">Symbology</span><span class="p">,</span>
    <span class="n">Toolbox</span><span class="p">,</span>
    <span class="nf">AttributeTable</span><span class="p">(</span><span class="n">LayerId</span><span class="p">),</span>
    <span class="nf">Map</span><span class="p">(</span><span class="n">MapViewId</span><span class="p">),</span>
<span class="p">}</span>

<span class="k">pub</span> <span class="k">struct</span> <span class="n">PanelState</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="n">kind</span><span class="p">:</span> <span class="n">PanelKind</span><span class="p">,</span>
    <span class="k">pub</span> <span class="n">open</span><span class="p">:</span> <span class="nb">bool</span><span class="p">,</span>                       <span class="c1">// closed ≠ removed: View menu reopens</span>
    <span class="c1">// position/size/collapsed are owned by egui's Area memory in Stage 1;</span>
    <span class="c1">// mirror them here only when you need save/restore (§5.3.6) or</span>
    <span class="c1">// programmatic layout ("Reset layout").</span>
<span class="p">}</span>

<span class="k">pub</span> <span class="k">struct</span> <span class="n">WorkspaceState</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="n">panels</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">PanelState</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div> <p>Rendering, in <code class="language-plaintext highlighter-rouge">AegisApp::ui</code> after the fixed chrome:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">egui</span><span class="p">::</span><span class="nn">CentralPanel</span><span class="p">::</span><span class="nf">default</span><span class="p">()</span>
    <span class="nf">.frame</span><span class="p">(</span><span class="nn">egui</span><span class="p">::</span><span class="nn">Frame</span><span class="p">::</span><span class="nf">new</span><span class="p">()</span><span class="nf">.fill</span><span class="p">(</span><span class="nn">theme</span><span class="p">::</span><span class="nn">palette</span><span class="p">::</span><span class="n">BG_CANVAS</span><span class="p">))</span>
    <span class="nf">.show</span><span class="p">(</span><span class="n">ui</span><span class="p">,</span> <span class="p">|</span><span class="n">ui</span><span class="p">|</span> <span class="p">{</span>
        <span class="nf">paint_canvas_background</span><span class="p">(</span><span class="n">ui</span><span class="p">);</span>            <span class="c1">// §5.3.4</span>
        <span class="nf">canvas_context_menu</span><span class="p">(</span><span class="n">ui</span><span class="p">,</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="n">commands</span><span class="p">);</span> <span class="c1">// "Add panel ▸ ..."</span>
        <span class="k">for</span> <span class="n">panel</span> <span class="k">in</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="py">.workspace.panels</span> <span class="p">{</span>
            <span class="nf">show_floating_panel</span><span class="p">(</span><span class="n">ui</span><span class="nf">.ctx</span><span class="p">(),</span> <span class="n">panel</span><span class="p">,</span> <span class="cm">/* state each kind needs */</span><span class="p">);</span>
        <span class="p">}</span>
    <span class="p">});</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">show_floating_panel</code> matches <code class="language-plaintext highlighter-rouge">PanelKind</code> and calls the §3.3 content functions:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">egui</span><span class="p">::</span><span class="nn">Window</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="nf">title_of</span><span class="p">(</span><span class="o">&amp;</span><span class="n">panel</span><span class="py">.kind</span><span class="p">))</span>
    <span class="nf">.id</span><span class="p">(</span><span class="nn">egui</span><span class="p">::</span><span class="nn">Id</span><span class="p">::</span><span class="nf">new</span><span class="p">(</span><span class="o">&amp;</span><span class="n">panel</span><span class="py">.kind</span><span class="p">))</span>     <span class="c1">// PanelKind: Hash — stable identity</span>
    <span class="nf">.open</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="n">panel</span><span class="py">.open</span><span class="p">)</span>              <span class="c1">// gives the ✕ button</span>
    <span class="nf">.default_size</span><span class="p">(</span><span class="nf">default_size_of</span><span class="p">(</span><span class="o">&amp;</span><span class="n">panel</span><span class="py">.kind</span><span class="p">))</span>
    <span class="nf">.constrain_to</span><span class="p">(</span><span class="n">canvas_rect</span><span class="p">)</span>          <span class="c1">// panels can't escape the artboard</span>
    <span class="nf">.show</span><span class="p">(</span><span class="n">ctx</span><span class="p">,</span> <span class="p">|</span><span class="n">ui</span><span class="p">|</span> <span class="k">match</span> <span class="o">&amp;</span><span class="n">panel</span><span class="py">.kind</span> <span class="p">{</span>
        <span class="nn">PanelKind</span><span class="p">::</span><span class="n">Contents</span>  <span class="k">=&gt;</span> <span class="nn">panels</span><span class="p">::</span><span class="nn">contents</span><span class="p">::</span><span class="nf">ui</span><span class="p">(</span><span class="n">ui</span><span class="p">,</span> <span class="n">project</span><span class="p">,</span> <span class="n">commands</span><span class="p">),</span>
        <span class="nn">PanelKind</span><span class="p">::</span><span class="n">Catalog</span>   <span class="k">=&gt;</span> <span class="nn">panels</span><span class="p">::</span><span class="nn">catalog</span><span class="p">::</span><span class="nf">ui</span><span class="p">(</span><span class="n">ui</span><span class="p">,</span> <span class="n">catalog</span><span class="p">,</span> <span class="n">drivers</span><span class="p">,</span> <span class="n">commands</span><span class="p">),</span>
        <span class="nn">PanelKind</span><span class="p">::</span><span class="nf">Map</span><span class="p">(</span><span class="n">id</span><span class="p">)</span>   <span class="k">=&gt;</span> <span class="p">{</span> <span class="cm">/* §5.3.5 */</span> <span class="p">}</span>
        <span class="o">...</span>
    <span class="p">});</span>
</code></pre></div></div> <p>Duplicates policy: most kinds are singletons (adding “Contents” twice is confusing) — <code class="language-plaintext highlighter-rouge">AppCommand::OpenPanel</code> should focus/reopen an existing panel of the same kind instead of pushing a second. Exceptions: <code class="language-plaintext highlighter-rouge">AttributeTable(id)</code> (one per layer) and <code class="language-plaintext highlighter-rouge">Map(id)</code> (many by design).</p> <h4 id="533-borrow-checker-note-learned-the-hard-way-if-ignored">5.3.3 Borrow-checker note (learned the hard way if ignored)</h4> <p>The loop above borrows <code class="language-plaintext highlighter-rouge">self.workspace.panels</code> mutably while each panel body needs <code class="language-plaintext highlighter-rouge">&amp;mut self.project</code>, <code class="language-plaintext highlighter-rouge">&amp;mut self.catalog</code>, etc. Structure <code class="language-plaintext highlighter-rouge">AegisApp</code> so these are <strong>sibling fields</strong> (they already are, per §3.1) and destructure once at the top of <code class="language-plaintext highlighter-rouge">ui</code>:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="k">Self</span> <span class="p">{</span> <span class="n">project</span><span class="p">,</span> <span class="n">catalog</span><span class="p">,</span> <span class="n">workspace</span><span class="p">,</span> <span class="n">drivers</span><span class="p">,</span> <span class="n">commands</span><span class="p">,</span> <span class="o">..</span> <span class="p">}</span> <span class="o">=</span> <span class="k">self</span><span class="p">;</span>
</code></pre></div></div> <p>Then the loop borrows <code class="language-plaintext highlighter-rouge">workspace</code> while panel bodies borrow the siblings — no conflict. This is precisely why panel content functions must take their dependencies as parameters (§3.3) instead of <code class="language-plaintext highlighter-rouge">&amp;mut AegisApp</code>.</p> <h4 id="534-the-canvas-itself">5.3.4 The canvas itself</h4> <ul> <li>Background: flat <code class="language-plaintext highlighter-rouge">BG_CANVAS</code>, plus an optional subtle dot-grid painted with <code class="language-plaintext highlighter-rouge">ui.painter()</code> (dots every N px, one <code class="language-plaintext highlighter-rouge">circle_filled</code> per visible grid point, or a faint line grid) — this is the single cheapest “this is an artboard, not a dialog” signal.</li> <li>Canvas context menu (same <code class="language-plaintext highlighter-rouge">ui.interact</code> technique as §4.1.3a): <strong>Add panel ▸</strong> (submenu per <code class="language-plaintext highlighter-rouge">PanelKind</code>), <strong>Reset layout</strong> (clears egui’s Area memory for the panel ids — repositions to defaults), later <strong>Save/Load layout</strong>.</li> <li><strong>Optional, later:</strong> panning/zooming the artboard itself (infinite canvas). Skip it for v1 — it forces you to reimplement window dragging in canvas-space and confuses map-zoom vs board-zoom. Add only if real usage wants more panels than fit on screen; <code class="language-plaintext highlighter-rouge">constrain_to</code> + scroll is the simpler v1 answer.</li> </ul> <h4 id="535-maps-as-panels-the-plural-is-the-point">5.3.5 Maps as panels (the plural is the point)</h4> <p>This is the deepest consequence of the artboard and the reason for the §3.1 refactor:</p> <ul> <li>Add <code class="language-plaintext highlighter-rouge">MapViewId</code> to <code class="language-plaintext highlighter-rouge">aegis-core/src/id.rs</code> — a second newtype identical in shape to <code class="language-plaintext highlighter-rouge">LayerId</code> (own <code class="language-plaintext highlighter-rouge">AtomicU64</code>; consider extracting a small macro or just copy the 10 lines).</li> <li><code class="language-plaintext highlighter-rouge">AegisApp.map_views: Vec&lt;MapViewState&gt;</code> — <strong>camera is per-view</strong>; <code class="language-plaintext highlighter-rouge">map_view::show(ui, project, &amp;mut view.camera)</code> already has the right signature and needs no changes to render inside a window. Two maps = two cameras over the same project; panning one doesn’t move the other. (Linked navigation — a “sync views” toggle copying camera state — is a trivial later feature <em>because</em> cameras are separate values.)</li> <li>The current <code class="language-plaintext highlighter-rouge">CentralPanel</code>-hosted single map is just the pre-artboard special case: <code class="language-plaintext highlighter-rouge">Map(main_view_id)</code> as the only, maximized panel. During the transition, keep the app working by making the artboard a <code class="language-plaintext highlighter-rouge">View ▸ Artboard workspace</code> toggle: <code class="language-plaintext highlighter-rouge">false</code> = today’s docked layout, <code class="language-plaintext highlighter-rouge">true</code> = floating. Delete the docked path once the artboard is solid.</li> <li>For now all maps show the whole project. “Which layers appear in which map” is a <em>document</em> question → when wanted, restructure <code class="language-plaintext highlighter-rouge">aegis-project::Project</code> into <code class="language-plaintext highlighter-rouge">maps: Vec&lt;MapDocument&gt;</code> where each <code class="language-plaintext highlighter-rouge">MapDocument</code> owns a layer stack (or layer-id list into a shared pool). That is a real model change touching Contents/attribute-table targeting — schedule it consciously; don’t back into it.</li> <li>Rendering cost: N maps = N × <code class="language-plaintext highlighter-rouge">render_egui::paint</code>. Fine on the CPU path for dev data; one more reason the wgpu path (§4.10.3) exists.</li> </ul> <h4 id="536-layout-persistence">5.3.6 Layout persistence</h4> <p>Users will arrange their artboard and expect it back on relaunch.</p> <ul> <li>Enable eframe persistence: <code class="language-plaintext highlighter-rouge">eframe = { ..., features = ["persistence"] }</code> and serde-derive the small app-state structs (<code class="language-plaintext highlighter-rouge">WorkspaceState</code>, <code class="language-plaintext highlighter-rouge">PanelState</code>, <code class="language-plaintext highlighter-rouge">PanelKind</code>, catalog connections as <code class="language-plaintext highlighter-rouge">Vec&lt;PathBuf&gt;</code>). egui already persists its own window positions/collapse state through eframe’s storage automatically once persistence is on — Stage 1 gets position memory nearly free.</li> <li>Implement <code class="language-plaintext highlighter-rouge">eframe::App::save</code> (<code class="language-plaintext highlighter-rouge">set_value(eframe::APP_KEY, &amp;snapshot)</code>) with a <code class="language-plaintext highlighter-rouge">WorkspaceSnapshot { workspace, folder_connections, theme_pref }</code>, and restore it in the <code class="language-plaintext highlighter-rouge">AegisApp</code> constructor from <code class="language-plaintext highlighter-rouge">cc.storage</code>. (The constructor change is the same <code class="language-plaintext highlighter-rouge">CreationContext</code> touch as §5.1.1 — do them together.)</li> <li>Note the id-stability requirement: <code class="language-plaintext highlighter-rouge">PanelKind::AttributeTable(LayerId)</code> and <code class="language-plaintext highlighter-rouge">Map(MapViewId)</code> contain process-unique ids that do <strong>not</strong> survive a restart (§4.8 wrinkle 1). Persist only id-free panels (or re-map ids on restore); simplest: on restore, drop attribute-table panels and re-create map panels fresh with new ids in saved positions… which requires saving positions yourself (mirror <code class="language-plaintext highlighter-rouge">Rect</code> into <code class="language-plaintext highlighter-rouge">PanelState</code> on save via <code class="language-plaintext highlighter-rouge">ctx.memory</code> area queries, or accept default positions for maps in v1). v1 recommendation: persist singleton panels + connections + theme; let maps/tables re-open manually.</li> </ul> <h3 id="54-fixed-chrome-menu-bar-and-status-bar">5.4 Fixed chrome: menu bar and status bar</h3> <p><strong>Menu bar</strong> (<code class="language-plaintext highlighter-rouge">panels/menu_bar.rs</code>) — replace the three dead buttons with real dropdowns (<code class="language-plaintext highlighter-rouge">egui::MenuBar</code> / <code class="language-plaintext highlighter-rouge">ui.menu_button</code>), everything emitting commands:</p> <ul> <li><strong>File:</strong> New project, Open…, Save, Save As… (§4.8); Add data… (opens the picker in file mode → §4.3); Quit (<code class="language-plaintext highlighter-rouge">ctx.send_viewport_cmd(egui::ViewportCommand::Close)</code>).</li> <li><strong>Edit:</strong> Undo/Redo — greyed placeholders until undo lands. (Undo design, for when it comes: command-pattern <code class="language-plaintext highlighter-rouge">ProjectEdit</code> enum with apply/revert in <code class="language-plaintext highlighter-rouge">aegis-project</code> — the project doc already claims undo history; the <code class="language-plaintext highlighter-rouge">AppCommand</code> queue from §3.2 is the natural capture point.)</li> <li><strong>View:</strong> one checkbox per <code class="language-plaintext highlighter-rouge">PanelKind</code> toggling <code class="language-plaintext highlighter-rouge">PanelState::open</code>; Zoom to full extent (§4.9); Theme ▸ Light/Dark/System (§5.1.2); Artboard workspace toggle (§5.3.5, transitional).</li> </ul> <p><strong>Status bar</strong> — new <code class="language-plaintext highlighter-rouge">panels/status_bar.rs</code>, <code class="language-plaintext highlighter-rouge">egui::Panel::bottom</code> before the central panel, fixed like the menu bar. Contents: cursor world coordinates from the hovered map view (§4.9.2), map CRS label (<code class="language-plaintext highlighter-rouge">Crs::Unknown</code> renders as “—” for now), running-job spinner + count (§4.7.3), transient one-line messages (the quiet sibling of toasts).</p> <hr/> <h2 id="6-error-surfacing-the-toast-system">6. Error surfacing: the toast system</h2> <p>The lint wall (no <code class="language-plaintext highlighter-rouge">unwrap</code>/<code class="language-plaintext highlighter-rouge">expect</code>/<code class="language-plaintext highlighter-rouge">panic</code>) means <strong>every</strong> <code class="language-plaintext highlighter-rouge">Result</code> needs a place to go. That place must exist <em>before</em> the catalog/driver work starts, or errors will get <code class="language-plaintext highlighter-rouge">let _ =</code>-swallowed under deadline pressure.</p> <p>New <code class="language-plaintext highlighter-rouge">crates/aegis-app/src/toasts.rs</code>:</p> <div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">pub</span> <span class="k">enum</span> <span class="n">ToastLevel</span> <span class="p">{</span> <span class="n">Info</span><span class="p">,</span> <span class="n">Warn</span><span class="p">,</span> <span class="n">Error</span> <span class="p">}</span>
<span class="k">pub</span> <span class="k">struct</span> <span class="n">Toast</span> <span class="p">{</span> <span class="n">level</span><span class="p">:</span> <span class="n">ToastLevel</span><span class="p">,</span> <span class="n">text</span><span class="p">:</span> <span class="nb">String</span><span class="p">,</span> <span class="n">created</span><span class="p">:</span> <span class="n">Instant</span> <span class="p">}</span>
<span class="nd">#[derive(Default)]</span>
<span class="k">pub</span> <span class="k">struct</span> <span class="n">Toasts</span> <span class="p">{</span> <span class="n">queue</span><span class="p">:</span> <span class="nb">Vec</span><span class="o">&lt;</span><span class="n">Toast</span><span class="o">&gt;</span> <span class="p">}</span>

<span class="k">impl</span> <span class="n">Toasts</span> <span class="p">{</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">push</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">level</span><span class="p">:</span> <span class="n">ToastLevel</span><span class="p">,</span> <span class="n">text</span><span class="p">:</span> <span class="k">impl</span> <span class="nb">Into</span><span class="o">&lt;</span><span class="nb">String</span><span class="o">&gt;</span><span class="p">)</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>
    <span class="cd">/// Draw as a stack of small cards, top-right, above everything</span>
    <span class="cd">/// (egui::Area, Order::Foreground, anchored RIGHT_TOP). Expire after</span>
    <span class="cd">/// ~6s (Error: sticky until clicked). Palette colors from §5.1.</span>
    <span class="k">pub</span> <span class="k">fn</span> <span class="nf">ui</span><span class="p">(</span><span class="o">&amp;</span><span class="k">mut</span> <span class="k">self</span><span class="p">,</span> <span class="n">ctx</span><span class="p">:</span> <span class="o">&amp;</span><span class="nn">egui</span><span class="p">::</span><span class="n">Context</span><span class="p">)</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div> <p><code class="language-plaintext highlighter-rouge">AppCommand::Toast</code> feeds it; <code class="language-plaintext highlighter-rouge">AegisApp::apply</code>’s error arms all end in a toast (<code class="language-plaintext highlighter-rouge">self.toasts.push(Error, e.to_string())</code> — <code class="language-plaintext highlighter-rouge">AegisError</code>’s <code class="language-plaintext highlighter-rouge">Display</code> is already user-readable). While any toast is alive, <code class="language-plaintext highlighter-rouge">request_repaint_after</code> so expiry doesn’t wait for mouse movement. (Crate alternative: <code class="language-plaintext highlighter-rouge">egui-notify</code>; but this is ~80 lines and full visual control is the theme of §5 — build it.)</p> <hr/> <h2 id="7-new-dependencies">7. New dependencies</h2> <p>All versions indicative; add to <code class="language-plaintext highlighter-rouge">[workspace.dependencies]</code> and reference with <code class="language-plaintext highlighter-rouge">workspace = true</code>.</p> <table> <thead> <tr> <th>Dependency</th> <th>Goes into</th> <th>For</th> <th>When</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">geojson</code></td> <td><code class="language-plaintext highlighter-rouge">aegis-io</code></td> <td>GeoJSON driver</td> <td>§4.2, early</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">egui_extras</code></td> <td><code class="language-plaintext highlighter-rouge">aegis-app</code></td> <td>virtualized attribute table</td> <td>§4.6</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">earcutr</code></td> <td><code class="language-plaintext highlighter-rouge">aegis-render</code></td> <td>polygon triangulation</td> <td>§4.10.1</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">serde</code> (+derive)</td> <td>feature-gated in core/vector/raster/project; plain in app</td> <td>persistence</td> <td>§4.8, §5.3.6</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">ron</code> (or <code class="language-plaintext highlighter-rouge">serde_json</code>)</td> <td><code class="language-plaintext highlighter-rouge">aegis-project</code>, <code class="language-plaintext highlighter-rouge">aegis-app</code></td> <td>project files, layout</td> <td>§4.8</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">shapefile</code></td> <td><code class="language-plaintext highlighter-rouge">aegis-io</code></td> <td>shapefile driver</td> <td>§4.2</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">tiff</code> / gdal-track</td> <td><code class="language-plaintext highlighter-rouge">aegis-io</code></td> <td>raster driver</td> <td>§4.2</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">rstar</code></td> <td><code class="language-plaintext highlighter-rouge">aegis-vector</code> or <code class="language-plaintext highlighter-rouge">aegis-render</code></td> <td>spatial index for culling/hit-test</td> <td>§4.10.3</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">wgpu</code>, <code class="language-plaintext highlighter-rouge">egui_wgpu</code>; eframe <code class="language-plaintext highlighter-rouge">wgpu</code> feature</td> <td><code class="language-plaintext highlighter-rouge">aegis-render</code>; <code class="language-plaintext highlighter-rouge">aegis-app</code></td> <td>GPU pipeline</td> <td>§4.10.3</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">notify</code></td> <td><code class="language-plaintext highlighter-rouge">aegis-io</code></td> <td>catalog auto-refresh</td> <td>optional, late</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">proj</code></td> <td>new <code class="language-plaintext highlighter-rouge">aegis-crs</code> or <code class="language-plaintext highlighter-rouge">aegis-io</code></td> <td>reprojection</td> <td>far future</td> </tr> <tr> <td><code class="language-plaintext highlighter-rouge">rfd</code></td> <td><code class="language-plaintext highlighter-rouge">aegis-app</code></td> <td>native dialogs <strong>only as stopgap</strong></td> <td>optional</td> </tr> </tbody> </table> <p>Deliberately absent: async runtimes (threads + mpsc suffice, §4.7.3), any docking crate like <code class="language-plaintext highlighter-rouge">egui_dock</code>/<code class="language-plaintext highlighter-rouge">egui_tiles</code> (the artboard is free-floating, not docked — <code class="language-plaintext highlighter-rouge">egui::Window</code> covers it; revisit <code class="language-plaintext highlighter-rouge">egui_tiles</code> only if you later want snap-docking zones).</p> <p>Every new dependency inherits the workspace lints via the existing <code class="language-plaintext highlighter-rouge">[lints] workspace = true</code> in each member — no action needed, but expect to write small lint-clean wrappers around crates that return panicky APIs.</p> <h2 id="8-cross-cutting-rules">8. Cross-cutting rules</h2> <h3 id="81-lint-survival-guide-the-workspace-denies-pedantic--nursery--panics">8.1 Lint survival guide (the workspace denies pedantic + nursery + panics)</h3> <p>Patterns that keep new code compiling, mapped to the situations this guide creates:</p> <table> <thead> <tr> <th>Situation</th> <th>Banned reflex</th> <th>Use instead</th> </tr> </thead> <tbody> <tr> <td><code class="language-plaintext highlighter-rouge">Option</code>/<code class="language-plaintext highlighter-rouge">Result</code> you “know” is fine</td> <td><code class="language-plaintext highlighter-rouge">.unwrap()</code></td> <td><code class="language-plaintext highlighter-rouge">let Some(x) = ... else { return }</code>, <code class="language-plaintext highlighter-rouge">map_or</code>, <code class="language-plaintext highlighter-rouge">?</code> + <code class="language-plaintext highlighter-rouge">AegisError</code>, toast</td> </tr> <tr> <td>Vec/map element access (attribute table!)</td> <td><code class="language-plaintext highlighter-rouge">v[i]</code></td> <td><code class="language-plaintext highlighter-rouge">.get(i)</code> + graceful <code class="language-plaintext highlighter-rouge">None</code> arm</td> </tr> <tr> <td>Counters, sizes (<code class="language-plaintext highlighter-rouge">row * width</code>)</td> <td>bare <code class="language-plaintext highlighter-rouge">+</code>/<code class="language-plaintext highlighter-rouge">*</code> on usize</td> <td><code class="language-plaintext highlighter-rouge">checked_*</code>/<code class="language-plaintext highlighter-rouge">saturating_*</code>, or restructure to iterators</td> </tr> <tr> <td>f64→f32 at paint time</td> <td><code class="language-plaintext highlighter-rouge">as f32</code></td> <td>only via <code class="language-plaintext highlighter-rouge">to_pos2</code> (and the future GPU boundary, §4.10.3)</td> </tr> <tr> <td>f32→f64 from egui input</td> <td><code class="language-plaintext highlighter-rouge">as f64</code></td> <td><code class="language-plaintext highlighter-rouge">f64::from(x)</code></td> </tr> <tr> <td>String truncation for labels</td> <td><code class="language-plaintext highlighter-rouge">&amp;s[..n]</code></td> <td><code class="language-plaintext highlighter-rouge">char_indices</code>-based helper or <code class="language-plaintext highlighter-rouge">Cow</code> + egui’s own <code class="language-plaintext highlighter-rouge">truncate()</code> layout</td> </tr> <tr> <td>“I’ll do this later” markers</td> <td><code class="language-plaintext highlighter-rouge">todo!()</code></td> <td><code class="language-plaintext highlighter-rouge">AegisError::NotYetImplemented { feature }</code> — it exists for this</td> </tr> <tr> <td>Docs on new pub items</td> <td>skipping them</td> <td>pedantic requires <code class="language-plaintext highlighter-rouge"># Errors</code> sections on fallible fns — copy the style already in <code class="language-plaintext highlighter-rouge">driver.rs</code></td> </tr> </tbody> </table> <p><code class="language-plaintext highlighter-rouge">--all-targets</code> means <strong>tests are linted too</strong>: prefer <code class="language-plaintext highlighter-rouge">Result</code>-returning tests (<code class="language-plaintext highlighter-rouge">fn t() -&gt; Result&lt;(), AegisError&gt;</code> + <code class="language-plaintext highlighter-rouge">?</code>) over <code class="language-plaintext highlighter-rouge">unwrap</code>; where a test genuinely wants indexing, a scoped <code class="language-plaintext highlighter-rouge">#[allow(clippy::indexing_slicing, reason = "test fixture of known shape")]</code> with a reason string is the established pattern (see <code class="language-plaintext highlighter-rouge">to_pos2</code>).</p> <h3 id="82-state-placement-summary">8.2 State placement summary</h3> <ul> <li><strong>Document state</strong> (survives save/load): <code class="language-plaintext highlighter-rouge">aegis-project</code> — layers, symbology values, selection, (later) undo, maps.</li> <li><strong>Session state</strong> (survives restart, not part of the document): <code class="language-plaintext highlighter-rouge">aegis-app</code> + eframe storage — workspace layout, folder connections, theme preference.</li> <li><strong>Frame/transient state</strong>: <code class="language-plaintext highlighter-rouge">aegis-app</code> structs — open dialogs, rename buffers, drag state, toasts, command queue.</li> <li><strong>Caches</strong> (derived, rebuildable): <code class="language-plaintext highlighter-rouge">CatalogCache</code> (io), triangulation/ texture caches (app, inside the render seam), never serialized.</li> </ul> <h3 id="83-the-seams-restated-as-what-not-to-touch">8.3 The seams, restated as “what NOT to touch”</h3> <ul> <li>Adding a file format → touch <code class="language-plaintext highlighter-rouge">aegis-io</code> only.</li> <li>Adding a geoprocessing tool → touch <code class="language-plaintext highlighter-rouge">aegis-geoprocessing</code> only.</li> <li>Adding a data kind (point cloud, TIN) → <code class="language-plaintext highlighter-rouge">LayerSource</code> variant + renderer arm only.</li> <li>Swapping/upgrading the renderer → inside <code class="language-plaintext highlighter-rouge">render_egui</code>/<code class="language-plaintext highlighter-rouge">MapRenderer</code> only.</li> <li>Restyling the whole app → <code class="language-plaintext highlighter-rouge">theme.rs</code> only.</li> <li>If a change forces edits outside its designated crate, stop and re-read §2 — the design is telling you something.</li> </ul> <h3 id="84-testing-expectations">8.4 Testing expectations</h3> <p>Lower crates are pure and must grow tests alongside features: <code class="language-plaintext highlighter-rouge">fit_rect</code> and existing camera math (<code class="language-plaintext highlighter-rouge">aegis-render</code>), <code class="language-plaintext highlighter-rouge">list_dir</code> classification/sorting against a <code class="language-plaintext highlighter-rouge">tempfile</code>-built tree (<code class="language-plaintext highlighter-rouge">aegis-io</code>, dev-dependency), driver round-trips on fixtures (§4.2), tool param validation (<code class="language-plaintext highlighter-rouge">aegis-geoprocessing</code>), project (de)serialization including the broken-link path (§4.8). <code class="language-plaintext highlighter-rouge">aegis-app</code> stays thin enough that manual testing + the compiler suffice for now; <code class="language-plaintext highlighter-rouge">egui_kittest</code> exists if panel logic ever gets hairy.</p> <hr/> <h2 id="9-suggested-build-order">9. Suggested build order</h2> <p>Each phase is shippable and sets up the next. Dependencies are noted; within a phase, items are parallelizable.</p> <p><strong>Phase 0 — Foundations (do first, small):</strong> §3 state refactor + command queue + panel content/container split; §6 toasts; §5.1 <code class="language-plaintext highlighter-rouge">theme.rs</code> skeleton applied via <code class="language-plaintext highlighter-rouge">CreationContext</code> (palette can start as “egui defaults, renamed” — the point is the <em>seam</em>).</p> <p><strong>Phase 1 — Catalog &amp; first data (the §4.1 example, end-to-end):</strong> §4.1 list_dir/cache/tree/context-menu/folder-picker → §4.2 GeoJSON driver → §4.3 add-to-map. <em>Exit criterion: right-click → connect a folder → expand → double-click a .geojson → it renders.</em></p> <p><strong>Phase 2 — Layer interaction:</strong> §4.4 contents (selection, reorder, rename, remove, zoom-to via §4.9 <code class="language-plaintext highlighter-rouge">fit_rect</code>), §4.5 symbology pane, §5.4 real menus + status bar.</p> <p><strong>Phase 3 — Data depth:</strong> §4.10.1 polygon fills; §4.6 attribute table (+ <code class="language-plaintext highlighter-rouge">egui_extras</code>); §4.2 shapefile driver (+ sidecar suppression).</p> <p><strong>Phase 4 — The artboard (the §5.3 example):</strong> Stage-1 floating windows behind a View toggle → maps-as-panels with per-view cameras (<code class="language-plaintext highlighter-rouge">MapViewId</code>) → canvas background + context menu → §5.3.6 persistence → retire the docked layout. Then §5.1 full theming pass (it lands best when the artboard exists to show it off).</p> <p><strong>Phase 5 — Geoprocessing:</strong> §4.7 tools, auto-dialogs, job runner, status-bar spinner.</p> <p><strong>Phase 6 — Persistence &amp; polish:</strong> §4.8 project save/load (serde features across crates, broken-link layers); §5.2 custom widgets pass; optionally undecorated window + custom title bar.</p> <p><strong>Phase 7 — Scale:</strong> §4.2 raster driver + §4.10.2 raster rendering; then §4.10.3 wgpu pipeline + <code class="language-plaintext highlighter-rouge">rstar</code> indexes when data sizes demand it.</p> <p>Rationale for the order: Phase 1 makes AeGIS <em>load real data</em>, which changes every subsequent decision from hypothetical to observed; the artboard waits until there are enough panels (Contents/Catalog/Symbology/Table) for a workspace to mean something; wgpu waits until real datasets prove where the CPU path breaks.</p>]]></content><author><name>Dylan J Roy-Leo</name></author><category term="aegis"/><category term="code"/><summary type="html"><![CDATA[A guide of the AeGIS codebase with instruction on how to contribute.]]></summary></entry><entry><title type="html">Linear Discriminant Analysis</title><link href="https://djroyleo.github.io/blog/2025/biostats-week9/" rel="alternate" type="text/html" title="Linear Discriminant Analysis"/><published>2025-10-28T00:00:00+00:00</published><updated>2025-10-28T00:00:00+00:00</updated><id>https://djroyleo.github.io/blog/2025/biostats-week9</id><content type="html" xml:base="https://djroyleo.github.io/blog/2025/biostats-week9/"><![CDATA[<h1 id="linear-discriminant-analysis-lda">Linear Discriminant Analysis (LDA)</h1> \[Pr(Y=k|X=x)=\frac{\pi_k \frac{1}{\sqrt{2\pi}\sigma}e^{-\frac{1}{2}\left( \frac{x-\mu_k}{\sigma} \right)^2}}{\sum_{l=1}^{K}\pi_l\frac{1}{\sqrt{2\pi}\sigma}e^{-\frac{1}{2}\left( \frac{x-\mu_l}{\sigma} \right)^2}}\] <ul> <li>Does a very good job at determining the probability than an observation $\left(Y,X\right)$ belong to a certain class given a value of $X$.</li> <li>The log odds derived from LDA is a linear function of predictor $X$.</li> <li>This linearity is a consequence of the Gaussian assumption for the class densities, as well as the assumption of a common covariance matrix.</li> <li>As seen, LSA and logistics regression have the same form of log odds. Both of whicha re linear functions of $x$. The only difference is the way the parameters are estimated. This same connection between LDA and logistic regression also holds for multidimensional data wit multiple predictors.</li> <li>In general, logistic regression is safer than LDA because LDA has strong assumptions about the normal distribution of $X$ and strong assumptions about the covariance matrix.</li> <li>LDA is useful: <ul> <li>when $n$ is small</li> <li>or the classes are well separated</li> <li>and Gaussian assumptions are reasonable</li> </ul> </li> </ul>]]></content><author><name>Dylan J Roy-Leo</name></author><category term="statistical-learning"/><category term="machine-learning"/><category term="LDA"/><summary type="html"><![CDATA[My notes on LDA from the textbook "An Introduction to Statistical Learning"]]></summary></entry><entry><title type="html">L1, L2, and the bet on sparsity</title><link href="https://djroyleo.github.io/blog/2025/L1-L2-bet-on-sparsity/" rel="alternate" type="text/html" title="L1, L2, and the bet on sparsity"/><published>2025-10-28T00:00:00+00:00</published><updated>2025-10-28T00:00:00+00:00</updated><id>https://djroyleo.github.io/blog/2025/L1-L2-bet-on-sparsity</id><content type="html" xml:base="https://djroyleo.github.io/blog/2025/L1-L2-bet-on-sparsity/"><![CDATA[<h1 id="l1">L1</h1>]]></content><author><name>Dylan J Roy-Leo</name></author><category term="statistical-learning"/><category term="machine-learning"/><summary type="html"><![CDATA[How do the L1 and L2 norms relate to each other and to the predictive power of statistical models in varying situations?]]></summary></entry><entry><title type="html">Shrinkage methods, ridge regression, and the LASSO</title><link href="https://djroyleo.github.io/blog/2025/biostats-week5/" rel="alternate" type="text/html" title="Shrinkage methods, ridge regression, and the LASSO"/><published>2025-09-30T00:00:00+00:00</published><updated>2025-09-30T00:00:00+00:00</updated><id>https://djroyleo.github.io/blog/2025/biostats-week5</id><content type="html" xml:base="https://djroyleo.github.io/blog/2025/biostats-week5/"><![CDATA[<h1 id="shrinkage-methods">Shrinkage methods</h1> <ul> <li><em>Shrinkage methods</em> fit a model using all <em>p</em> predictors, using a technique that <strong>shrinks</strong> or <strong>regularizes</strong> the coefficient estimates towards zero.</li> </ul> <h2 id="ridge-regression">Ridge regression</h2> <p>Least squares seeks $\beta_0, \ \beta_1, \ \dots, \ \beta_p$ that minimizes</p> \[RSS=\sum_{i=1}^n \left( y_i-\beta_0-\sum_{j=1}^p \beta_jx_{jj} \right)^2\] <p>Ridge regression seeks $\beta_0, \ \beta_1, \ \dots, \ \beta_p$ that minimizes</p> \[\sum_{i=1}^n \left( y_i-\beta_0-\sum_{j=1}^p \beta_jx_{jj} \right)^2 + \lambda\sum_{j=1}^p\beta_j^2=RSS+\lambda\sum_{j=1}^p\beta_j^2\] <p>$\lambda \ge 0$ is a <em>tuning parameter</em> that controls the amount of shrinkage</p> <ul> <li>When $\lambda=0$, get least squares estimates.</li> <li>When $\lambda &gt; 0$, get estimates that shrunken towards zero.</li> </ul> <p>$\lambda\sum_{j=1}^p\beta_j^2$ : shrinkage penalty</p> <p>The shrinkage penalty is applied to $\beta_1, \ \beta_2, \ \dots, \ \beta_p$ but not to the intercept $\beta_0$.</p> <h2 id="ridge-regression-matrix-expression">Ridge regression matrix expression</h2> <p>Assume that $y=(y_1, \ \dots, \ y_n)^T$ is centered. Let $\beta = (\beta_1, \ \dots, \ \beta_p)^T$ and</p> \[X=\begin{bmatrix} X_{11} &amp; X_{12} &amp; \dots &amp; X_{1p} \\ X_{21} &amp; X_{22} &amp; \dots &amp; X_{2p} \\ \vdots &amp; \vdots &amp; \ddots &amp; \vdots \\ X_{n1} &amp; X_{n2} &amp; \dots &amp; X_{np} \end{bmatrix}\] <p>Ridge regression seeks $\beta$ that minimizes the following penalized residual sum of squares (<strong>PRSS</strong>).</p> \[PRSS(\beta)=(y-X\beta)^T(y-X\beta)+\lambda\|\beta\|_2^2\] <p>Take derivatives, we obtain</p> \[\frac{\partial PRSS(\beta)}{\partial\beta}=-2X^T(y-X\beta)+2\lambda\beta\] <p>The solution to $PRSS(\beta)$ is</p> \[\hat{\beta}_{\lambda}^{\ ridge}=(X^TX+\lambda I_p)^{-1}X^Ty\] <p>Even if $X^TX$ is not invertible, inclusion of $\lambda$ makes the problem non-singular. This was the original motivation for ridge regression (Hoerl &amp; Kennard, 1970).</p>]]></content><author><name>Dylan J Roy-Leo</name></author><category term="statistical-learning"/><category term="machine-learning"/><category term="shrinkage-methods"/><category term="ridge-regression"/><category term="LASSO"/><summary type="html"><![CDATA[My notes on Shrinkage from the textbook "An Introduction to Statistical Learning"]]></summary></entry><entry><title type="html">Subset Selection</title><link href="https://djroyleo.github.io/blog/2025/biostats-week4/" rel="alternate" type="text/html" title="Subset Selection"/><published>2025-09-25T00:00:00+00:00</published><updated>2025-09-25T00:00:00+00:00</updated><id>https://djroyleo.github.io/blog/2025/biostats-week4</id><content type="html" xml:base="https://djroyleo.github.io/blog/2025/biostats-week4/"><![CDATA[<h1 id="subset-selection">Subset selection</h1> <p>With the linear model of the form:</p> \[Y=\beta_0+\beta_1X_1+\beta_2X_2+\dots+\beta_pX_p+\epsilon\] <p>which has many advantages such as simplicity and interpretability and is typically fit with least squares, sometime we can get better results by replacing ordinary least squares fitting with some alternative fitting procedures. Alternative fitting procedures can yield better prediction accuracy.</p> <h1 id="prediction-accuracy">Prediction accuracy</h1> <ul> <li>Bias: If the true relationship between the response and the predictors is approximately linear, the least squares estimates will have low bias.</li> <li>Variance:</li> <li>$n»p:$ the least squares estimates tend to also have low variance, and hence will perform well on test observations.</li> <li>$n$ is not $»p$: a lot of variability in the least squares fit, resulting in overfitting and consequently poor predictions on future observations not used in model training.</li> <li>$p&gt;n$: no longer a unique least squares coefficient estimate, the variance is <em>infinite</em> so the method cannot be used at all.</li> </ul> <p>We can constrain or shrink the estimated coefficients, thus, substantially reducing the variance at the cost of a negligible increase in bias. This can lead to substantial improvements in the accuracy with which we can predict the response for observations not used in model training.</p> <h1 id="model-interpretability">Model interpretability</h1> <ul> <li>Often, some or many of the variables used in multiple regression models are in fact not associated with the response. <ul> <li>Leads to unnecessary complexity</li> </ul> </li> </ul> <h1 id="three-classes-of-methods">Three classes of methods</h1> <ol> <li>Subset selection: Identify a subset of the $p$ features that appear to be associated with the response. Then fit a model on those features using least squares.</li> <li>Shrinkage: Use all $p$ features to fit a model using a technique that shrinks coefficient estimates towards zero relative to least squares. This regularization results in reduced variance. Depending on what type of shrinkage is preformed, some of the coefficients may be estimated to be exactly zero. Hence, shrinkage methods can also perform variable selection.</li> <li>Dimension reduction: Project the $p$ predictors onto an M-dimensional subspace ($M &lt; p$). This is achieved by computing $M$ different linear combinations, or projections, of the variables. Then use these $M$ projections as predictors in a model fit using least squares.</li> </ol> <h1 id="subset-selection-methods">Subset selection methods:</h1> <ul> <li>Simple to understand/implement</li> <li>Three types: <ul> <li>Best subset selection: consider every possible model and choose the best one</li> <li>Forward step-wise</li> <li>Backwards step-wise</li> </ul> </li> </ul> <h2 id="best-subset-selection">Best subset selection</h2> <p>Let $M_0$ denote the null model, which contains no predictors. This model simply predicts the sample mean for each observation.</p> <p>For $k=1,2,\dots,p$:</p> <ul> <li>Fit all $\begin{pmatrix} p <br/> k \end{pmatrix}$ models that contain exactly $k$ predictors</li> <li>Pick the best among these models, and call it $M_k$. Here best is defined as having the smallest RSS, or equivalently largest $R^2$.</li> </ul> <p>Select a single best model from among, $M_0,\dots,M_p$ using cross-validated prediction error, $C_p(\text{AIC})$, $\text{BIC}$, or adjusted $R^2$.</p> <h2 id="forward-stepwise-selection">Forward stepwise selection</h2> <p>Comments:</p> <ul> <li>Huge computational advantage over best subset selection: $1+p(p+1)/2$ versus $2^p$</li> <li>Not guaranteed to find the best model out of all $2^p$ possible models involving $p$ parameters</li> <li>Can be applied even in the high dimensional setting where $n&gt;p$.</li> </ul> <h2 id="backward-stepwise-selection">Backward stepwise selection</h2> <p>Starts with a model containing all of the predictors, and remove predictors, one-at-a-time. At each step, remove the predictor that is least useful in predicting the response.</p> <ul> <li>Let $M_p$ denote the full model, which contains all $p$ predictors.</li> <li>For $k=p, p-1, \dots, 1:$ <ul> <li>Consider all $k$ models that contain all but one of the predictors in $M_k$ for a total of $k-1$ predictor.</li> <li>Choose the best among these $k$ models, and call it $M_{k-1}$. Here best is defined as having the smallest RSS, or highest $R^2$.</li> </ul> </li> <li>Select a single best model from among $M_0,\dots,M_p$ using cross validated prediction error, $C_p(\text{AIC})$, $\text{BIC}$ or adjusted $R^2$.</li> </ul> <p>Comments:</p> <ul> <li>Like forward stepwise, backward stepwise has a huge computational advantage over best subset selection (exact computational advantage is the same as forwards stepwise).</li> <li>Like forward stepwise, not guaranteed to fin the best model out of all $2^p$ possible models involving $p$ predictors.</li> <li>Unlike forward stepwise, can be applied only when $n&gt;p$: must have more observations than features in order to fit the initial model containing all predictors.</li> </ul> <h1 id="takeaways">Takeaways</h1> <ul> <li>Naive use of classical linear model theory after model selection ignores data-dependence and is generally invalid.</li> <li>With AIC/BIC, overfitted models yield downward-biased error variance estimates.</li> <li>As a result, AIC-based prediction intervals are too short and undercover.</li> <li>Be cautious when reporting uncertainty after AIC/BIC-based selection.</li> <li>Consider selective/post-selective inference methods or fully pre-specified models.</li> </ul>]]></content><author><name>Dylan J Roy-Leo</name></author><category term="statistical-learning"/><category term="machine-learning"/><category term="subset-selection"/><summary type="html"><![CDATA[My notes on subset selection methods from the textbook "An Introduction to Statistical Learning"]]></summary></entry></feed>