git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

src/render.rs (63.7K)

   1 
   2 use cce_ui::colors;
   3 use cce_ui::widget::WidgetHost;
   4 
   5 use crate::app::State;
   6 use crate::slots::{
   7     WIDGET_COUNT,
   8     CONTENT_IDX, VIEWPORT_IDX, PARAM_IDX,
   9     BREADCRUMB_IDX, HEADER_IDX, RIGHT_MENUBAR_IDX,
  10     SPREADSHEET_MENUBAR_IDX, SPREADSHEET_IDX,
  11     LEFT_MENUBAR_IDX, PARAM_MENUBAR_IDX, NETWORK_PANEL_IDX, PLAYBAR_IDX,
  12     DIALOG_IDX,
  13 };
  14 use crate::geometry::network_sphere_vertices_with_errors;
  15 use cce_ui::scene::layout::Rect;
  16 use cce_ui::scene::paint::{DisplayList, PaintCtx, Prim};
  17 use cce_ui::scene::painter::{append_widget_plate, append_widget_plate_radii, append_widget_text};
  18 
  19 const TAU: f32 = 2.0 * std::f32::consts::PI;
  20 
  21 fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
  22     Rect { x, y, width: w, height: h }
  23 }
  24 
  25 /// Intersect two logical `[l, t, r, b]` text bounds.
  26 fn merge_bounds(a: Option<[f32; 4]>, b: Option<[f32; 4]>) -> Option<[f32; 4]> {
  27     match (a, b) {
  28         (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
  29         (Some(a), None) => Some(a),
  30         (None, b) => b,
  31     }
  32 }
  33 
  34 impl State {
  35     /// Per-corner plate radii for a pane rect: a corner that sits ON a window
  36     /// corner is this pane's share of the window silhouette — the compositor
  37     /// clips the window at the span-widened root plate arc, so the pane wears
  38     /// that arc there (what a full-window root plate does in one piece).
  39     /// Interior corners keep the widget-scale nominal plate radius, matching
  40     /// the squircles of the sibling panes around them.
  41     fn pane_plate_radii(&self, x: f32, y: f32, w: f32, h: f32) -> (f32, f32, f32, f32) {
  42         // Delegates to the toolkit since RFC Phase 7b moved this math into
  43         // PlateSpec: window-corner arcs follow the SHARED silhouette curve
  44         // (never the app-merged plate override), interior corners the nominal
  45         // plate radius. This function was the reference implementation.
  46         use cce_ui::scene::paint::PlateSpec;
  47         let flags = PlateSpec::window_corner_flags(
  48             cce_ui::scene::layout::Rect { x, y, width: w, height: h },
  49             self.width,
  50             self.height,
  51         );
  52         PlateSpec::radii_for(flags)
  53     }
  54 
  55     /// The rounded-rect clip (plate rect + corner radius) a widget's content must stay
  56     /// inside, so children of plates cut off at the plate's rounded corners: the network
  57     /// plate for the network pane's parts (graph content, breadcrumb), the pane's own
  58     /// plate for the params and spreadsheet panes. `None` with square corners, and for
  59     /// the circular network pane (which clips by circle instead).
  60     fn plate_rounded_clip(&self, idx: usize) -> Option<(Rect, f32)> {
  61         let r = cce_ui::layout::plate_corner_radius();
  62         if r <= 0.0 {
  63             return None;
  64         }
  65         let (x, y, w, h) = match idx {
  66             CONTENT_IDX | BREADCRUMB_IDX if !self.circular_network_pane => {
  67                 self.positions[NETWORK_PANEL_IDX]
  68             }
  69             crate::slots::CONTENT2_IDX | crate::slots::BREADCRUMB2_IDX => {
  70                 self.positions[crate::slots::NETWORK_PANEL2_IDX]
  71             }
  72             PARAM_IDX => self.positions[PARAM_IDX],
  73             SPREADSHEET_IDX => self.positions[SPREADSHEET_IDX],
  74             _ => return None,
  75         };
  76         if w <= 0.0 || h <= 0.0 {
  77             return None;
  78         }
  79         Some((rect(x, y, w, h), r))
  80     }
  81 
  82     /// The frame's entire 2D content — geometry AND text — as one display list (the
  83     /// engine's single paint path; `Application::display_list_text` opts the designer's
  84     /// text into the engine's shaping/glyph pass, so the app-side FontSystem and buffer
  85     /// cache are gone). Draw order is the hand-maintained slot order the vertex path
  86     /// used; the circular network pane rides `PaintItem::clip_circle`.
  87     pub(crate) fn collect_display_list(&mut self) -> DisplayList {
  88         // Refresh popover registration: the engine's text-occlusion clamp
  89         // reads `ui_context.active_popovers` to keep underlying text from
  90         // bleeding through an open popup's plate. The legacy render_widget
  91         // helper registered these as a side effect; the designer's own paint
  92         // walk must do it explicitly or open dropdowns get no occlusion.
  93         self.ui_context.clear_popovers();
  94         for i in 0..WIDGET_COUNT {
  95             if self.slots.get_dyn(i).visible() && self.slots.get_dyn(i).popover_rect().is_some() {
  96                 self.ui_context.register_popover(self.slots.get_dyn_mut(i));
  97             }
  98         }
  99 
 100         let show_cursor = self.drag_widget.is_none() && self.app_drag.is_none();
 101 
 102         // The graph content clip (node quads, cursor) and the circular pane clip.
 103         let clip = if self.circular_network_pane {
 104             rect(
 105                 self.circular_network_layout.x - self.circular_network_layout.r,
 106                 self.circular_network_layout.y - self.circular_network_layout.r,
 107                 2.0 * self.circular_network_layout.r,
 108                 2.0 * self.circular_network_layout.r,
 109             )
 110         } else {
 111             let (cx, cy, cw, ch) = self.positions[CONTENT_IDX];
 112             rect(cx, cy, cw, ch)
 113         };
 114         let clip_circle = if self.circular_network_pane {
 115             Some([
 116                 self.circular_network_layout.x,
 117                 self.circular_network_layout.y,
 118                 self.circular_network_layout.r,
 119             ])
 120         } else {
 121             None
 122         };
 123 
 124         let mut draw_order: Vec<usize> = (0..WIDGET_COUNT).collect();
 125         draw_order.sort_by_key(|&i| {
 126             // PAGE_IDX shares the viewport's layer, not the roster's tail.
 127             // The viewport is full-bleed and the other panes float OVER it, so
 128             // a pane that takes the viewport's rect has to take its depth too
 129             // — drawn last it covers the collapsed stubs and the corner dots,
 130             // which then show through as ghost text from the later label pass.
 131             let base_key = if i == VIEWPORT_IDX
 132                 || i == crate::slots::PAGE_IDX
 133                 || i == NETWORK_PANEL_IDX
 134                 || i == crate::slots::NETWORK_PANEL2_IDX
 135             {
 136                 -5
 137             } else if i == CONTENT_IDX || i == crate::slots::CONTENT2_IDX || i == PARAM_IDX {
 138                 -4
 139             } else if i == HEADER_IDX
 140                 || i == LEFT_MENUBAR_IDX
 141                 || i == RIGHT_MENUBAR_IDX
 142                 || i == PARAM_MENUBAR_IDX
 143                 || i == SPREADSHEET_MENUBAR_IDX
 144             {
 145                 -3
 146             } else {
 147                 self.slots.get_dyn(i).z_index()
 148             };
 149             (self.has_any_open_menu(i), base_key)
 150         });
 151 
 152         // root plate container DISSOLVED (Phase 6as): register the widgets (registry consumers:
 153         // coverage/parent walks) and paint each top-level widget directly in sorted order.
 154         self.ui_context.clear_hierarchy();
 155         let widget_ptrs: Vec<*mut (dyn WidgetHost + 'static)> = (0..WIDGET_COUNT)
 156             .map(|i| self.slots.get_dyn(i) as *const (dyn WidgetHost + 'static) as *mut (dyn WidgetHost + 'static))
 157             .collect();
 158         // Register ALL slots, visible or not (id-rooted router): the wheel loop and the
 159         // hidden-widget broadcasts dispatch by id over the whole roster, and visibility
 160         // gates behavior inside the widget — an unregistered hidden root would drop the
 161         // event before that gate.
 162         for i in 0..WIDGET_COUNT {
 163             let w = self.slots.get_dyn(i);
 164             self.ui_context.register_widget(w.base().id(), w as *const (dyn WidgetHost + 'static) as *mut (dyn WidgetHost + 'static));
 165         }
 166 
 167         let mut pc = PaintCtx::new();
 168         let mut visited = vec![false; WIDGET_COUNT];
 169         for &i in &draw_order {
 170             if !self.slots.get_dyn(i).visible() {
 171                 continue;
 172             }
 173             // The dialog is painted after the overlay passes below, not in the
 174             // walk. A high z_index is not enough: `append_frame_text` and the
 175             // viewport overlays run AFTER the whole walk, so the graph's node
 176             // labels and the scale readout drew straight over a dialog that
 177             // had already covered them.
 178             if i == DIALOG_IDX {
 179                 continue;
 180             }
 181             unsafe {
 182                 self.paint_element(&*widget_ptrs[i], &mut pc, show_cursor, &mut visited, clip, clip_circle);
 183             }
 184         }
 185 
 186         self.append_context_border(&mut pc);
 187         self.append_frame_text(&mut pc);
 188         self.append_point_numbers(&mut pc);
 189         self.append_scale_readout(&mut pc);
 190         self.append_viewer_state_overlay(&mut pc);
 191         self.append_popovers(&mut pc);
 192         self.append_dock_drag_overlay(&mut pc);
 193         self.append_plate_corners(&mut pc);
 194         // Above every pane AND every overlay text pass, below only the context
 195         // menu — which can be opened from inside it.
 196         self.append_dialog(&mut pc, show_cursor, &mut visited, clip);
 197 
 198         // The context menu (node/viewport right-click AND the plate corner
 199         // menus — one shared state) floats above everything, drawn last as
 200         // the toolkit's lit plate: rounded, translucent, frosted — the
 201         // material every other floating surface wears. NOT the legacy
 202         // extra_quads loop, which is the square opaque pre-frost look.
 203         // Its labels carry bounds equal to the menu rect so the engine's text-
 204         // occlusion clamp (which registers the menu rect) exempts them.
 205         // One call for plate and labels: a TextLabel carries no family, so the
 206         // hand-rolled `paint` + `text_labels()` pair here passed None and drew
 207         // the menu in the default sans instead of the DE's menu font.
 208         cce_ui::widget::context_menu::paint_with_labels(&mut pc);
 209 
 210         pc.finish()
 211     }
 212 
 213     fn paint_widget(
 214         &self,
 215         idx: usize,
 216         pc: &mut PaintCtx,
 217         show_cursor: bool,
 218         visited: &mut [bool],
 219         clip: Rect,
 220         clip_circle: Option<[f32; 3]>,
 221     ) {
 222         if idx >= visited.len() || visited[idx] {
 223             return;
 224         }
 225         visited[idx] = true;
 226 
 227         let w = self.slots.get_dyn(idx);
 228         if !w.visible() {
 229             return;
 230         }
 231 
 232         // A collapsed pane is its title stub and nothing else: the plate, the
 233         // name, and (from the later corner pass) the control that restores it.
 234         // Returning here is what suppresses the body — the params rows, the
 235         // spreadsheet grid, the transport controls — rather than relying on
 236         // each pane's own clip to hide content taller than the stub.
 237         if let Some(stub_label) = self.pane_stub_label(idx) {
 238             let (sx, sy, sw, sh) = w.rect();
 239             append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(sx, sy, sw, sh));
 240             let font_size = 12.0;
 241             let ty = cce_ui::layout::align_text_y(sy, sh, font_size, 0.0);
 242             // Bounds stop at the corner control so a long name cannot run under it.
 243             let text_right = sx + sw - 2.0 * crate::plate_corner::CORNER_INSET;
 244             pc.text_with(
 245                 stub_label,
 246                 sx + 12.0,
 247                 ty,
 248                 font_size,
 249                 [0xcc, 0xcc, 0xd4],
 250                 None,
 251                 Some([sx, sy, text_right, sy + sh]),
 252             );
 253             return;
 254         }
 255 
 256         let is_network_part = idx == CONTENT_IDX || idx == LEFT_MENUBAR_IDX || idx == BREADCRUMB_IDX || idx == NETWORK_PANEL_IDX;
 257         let active_circle = if is_network_part { clip_circle } else { None };
 258         if let Some(c) = active_circle {
 259             pc.push_clip_circle(c);
 260         }
 261 
 262         // Children of plates cut off at the plate's rounded corners (the param pane's
 263         // popovers escape this: they render later via `append_popovers`).
 264         let rounded_clip = self.plate_rounded_clip(idx);
 265         if let Some((rc, rr)) = rounded_clip {
 266             pc.push_clip_rounded(rc, rr);
 267         }
 268 
 269         if idx == NETWORK_PANEL_IDX && self.circular_network_pane {
 270             let cx = self.circular_network_layout.x;
 271             let cy = self.circular_network_layout.y;
 272             let r = self.circular_network_layout.r;
 273             // Circles carry no blur-behind marker (only Plate/Bevel prims do) — a
 274             // negative alpha from the params fill would render garbage, so clamp it.
 275             let mut fill = w.color();
 276             fill[3] = fill[3].abs();
 277             pc.circle(cx, cy, r, fill);
 278             pc.arc(cx, cy, r, 3.0, 0.0, TAU, [0.35, 0.65, 0.95, 0.80 * self.network_opacity]);
 279         } else if idx == DIALOG_IDX {
 280             // Modern-paint surface, the playbar's contract: the designer
 281             // authors the plate (the dialog floats, so the pane radii and the
 282             // focus tint do not apply — it is never a pane and never the
 283             // focused one), then Dialog::paint emits the query line and the
 284             // rows. A subtree painter, so append_frame_text skips the slot and
 285             // the chord column keeps its own font and bounds.
 286             //
 287             // The plate is `append_widget_plate`'s, drawn here rather than by
 288             // it because that helper builds its material from the fill alone
 289             // and the dialog wants its own backdrop compression
 290             // (`State::dialog_compression`) — the node bodies' override, for
 291             // the same reason: the one knob that differs from the panes.
 292             let (x, y, ww, h) = w.rect();
 293             let r = rect(x, y, ww, h);
 294             let cr = w.corner_radii();
 295             let radii = (cr.top_left, cr.top_right, cr.bottom_right, cr.bottom_left);
 296             let mat = crate::dialog::plate_material(self.dialog_compression);
 297             match w.solid_border() {
 298                 Some(_) if cce_ui::layout::control_relief() => {
 299                     pc.bevel(r, radii, &mat, cce_ui::colors::plate_bevel_width());
 300                 }
 301                 Some((border, thickness)) => {
 302                     pc.fill_material(r, radii, &mat);
 303                     pc.border(r, radii, [0.0; 4], border, thickness);
 304                 }
 305                 None => pc.fill_material(r, radii, &mat),
 306             }
 307             w.paint_self(&self.ui_context, pc);
 308         } else if idx == PLAYBAR_IDX {
 309             // Modern-paint pane: the plate from the legacy views like the other
 310             // panes, then paint_self emits the transport controls — geometry AND
 311             // text (a subtree painter; append_frame_text skips this slot so the
 312             // text isn't doubled).
 313             let (wx, wy, ww2, wh2) = w.rect();
 314             append_widget_plate_radii(w, pc, None, self.pane_plate_radii(wx, wy, ww2, wh2));
 315             w.paint_self(&self.ui_context, pc);
 316         } else if idx == BREADCRUMB_IDX || idx == crate::slots::BREADCRUMB2_IDX {
 317             // Modern-paint control: Breadcrumb's whole look lives in its
 318             // Paint::paint() (the cce-ui restyle — per-segment plates on the
 319             // dropdown's relief, slanted seams) and it serves NO legacy views,
 320             // so the fall-through branch rendered bare labels on the network
 321             // plate. Unlike the playbar it is not a subtree painter: paint_self
 322             // drops the widget's Text prims, and the labels keep coming from
 323             // append_frame_text like every other slot — no doubling.
 324             w.paint_self(&self.ui_context, pc);
 325         } else if idx == SPREADSHEET_IDX {
 326             // Modern-paint pane: the plate from the designer (span-widened
 327             // radii + focus tint), then Spreadsheet::paint authors the grid —
 328             // header band, zebra rows, separators, dividers, scrollbar. A
 329             // subtree painter since cce-ui@f1cd939: its text passes through
 330             // paint_self verbatim, carrying the per-column clamp bounds the
 331             // own-labels bridge would drop; append_frame_text skips the slot.
 332             let (wx, wy, ww2, wh2) = w.rect();
 333             append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
 334             w.paint_self(&self.ui_context, pc);
 335         } else if idx == crate::slots::PAGE_IDX {
 336             // Modern-paint pane, like the spreadsheet: the designer authors the
 337             // plate (span-widened radii, focus tint) and ImageView::paint fits
 338             // the sheet into it. The fall-through branch below serves LEGACY
 339             // widgets — it emits a plate and the widget's legacy views — so a
 340             // widget whose whole look lives in Paint::paint lands there and
 341             // draws nothing at all, which is exactly what this pane did before
 342             // the branch existed: visible, correctly placed, and blank.
 343             let (wx, wy, ww2, wh2) = w.rect();
 344             append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
 345             w.paint_self(&self.ui_context, pc);
 346         } else if idx == VIEWPORT_IDX {
 347             // The scene viewer's lip is the window's own root plate edge: the
 348             // 3D canvas is full-bleed (CANVAS_IDX covers the window; the other
 349             // panes float over it), so the lip spans the WHOLE window with the
 350             // window radius on all four corners (the SHARED silhouette value,
 351             // concentric with the compositor clip). Under control_relief it is
 352             // the fill-less ROLL OVERLAY (negative-depth Plate): exactly the
 353             // roll other windows' root plates wear — same width, profile,
 354             // crest and specular, full band inside the silhouette — screened
 355             // over the 3D scene, since a filled Plate would cover it (and a
 356             // frosting fill would blur it). It replaced the Boss rim, whose
 357             // boundary-straddling wall lost its outer half to the compositor
 358             // clip: the visible band ran half a roll wide and started at
 359             // mid-slope. Focus adds the fill-less tinted Bevel — the wrapped
 360             // accent glint on the same silhouette, the network cursor's prim —
 361             // matching the focused plates' treatment (shading unchanged, glint
 362             // in accent). Without control_relief it degrades to the flat
 363             // plate-border stroke, exactly a bordered plate's outline→relief
 364             // degradation, and append_context_border owns the focus ring.
 365             if let Some(bc) = cce_ui::colors::plate_border_color() {
 366                 let (px, py, pw, ph) = (0.0, 0.0, self.width, self.height);
 367                 if pw > 0.0 && ph > 0.0 {
 368                     let vp_rect = rect(px, py, pw, ph);
 369                     let radii = self.pane_plate_radii(px, py, pw, ph);
 370                     if cce_ui::layout::control_relief() {
 371                         // Window-edge roll width, NOT plate_bevel_width: the lip
 372                         // matches the root plates of other windows
 373                         // (style.surface.relief width), not the designer's
 374                         // interior pane plates.
 375                         let depth = cce_ui::layout::bevel_width();
 376                         pc.plate_spec(&cce_ui::scene::paint::PlateSpec {
 377                             rect: vp_rect,
 378                             material: cce_ui::scene::Material::opaque([0.0; 4]),
 379                             window_corners: (true, true, true, true),
 380                             depth: -depth,
 381                         });
 382                         if let Some(tint) = self.plate_focus_tint(idx) {
 383                             pc.bevel_tinted(vp_rect, radii, &cce_ui::scene::Material::from_fill([0.0; 4]), depth, tint);
 384                         }
 385                     } else {
 386                         pc.border(vp_rect, radii, [0.0; 4], bc, cce_ui::colors::plate_border_thickness());
 387                     }
 388                 }
 389             }
 390             for (qx, qy, qw, qh, qc) in w.extra_quads() {
 391                 pc.quad(rect(qx, qy, qw, qh), qc);
 392             }
 393             for (cx, cy, cr, cc) in w.extra_circles() {
 394                 pc.circle(cx, cy, cr, cc);
 395             }
 396         } else if idx == CONTENT_IDX || idx == crate::slots::CONTENT2_IDX {
 397             let second = idx == crate::slots::CONTENT2_IDX;
 398             // The passed-in `clip` is PANE 1's content rect (computed once,
 399             // before the walk) — zero whenever pane 1 waits as a tab. The
 400             // second editor clips to its OWN rect or its whole graph
 401             // vanishes with pane 1's.
 402             let clip = if second {
 403                 let (cx2, cy2, cw2, ch2) = self.positions[crate::slots::CONTENT2_IDX];
 404                 rect(cx2, cy2, cw2, ch2)
 405             } else {
 406                 clip
 407             };
 408             // No plate here: the pane's plate is NETWORK_PANEL_IDX's
 409             // PassivePlate (the params material, gated on `network_plate`
 410             // in the generic arm). Until 2026-09-20 this arm ALSO painted
 411             // the graph widget's own background over it — the cell colour
 412             // at the network opacity, a flat blue-grey wash that predated
 413             // the plate and made the network pane the one pane with a hue.
 414 
 415             pc.clip(clip, |pc| {
 416                 // Node bodies wear the parameter plate's fill exactly — same
 417                 // tint, opacity, and blur-behind marker (param_plate_fill) —
 418                 // drawn as beveled mini-plates on the DE corner family. They
 419                 // draw as ONE consecutive run so the renderer's blur snapshot
 420                 // is shared across all of them (one full-frame copy, not one
 421                 // per node). Wires/grid/axes stay flat BEHIND the nodes; the
 422                 // geometry toggles stay flat ON TOP. A selected/dragged node
 423                 // keeps the identical fill but glints via a highlight specular
 424                 // tint on its bevel, so selection still reads.
 425                 let node_r = cce_ui::layout::graph_node_corner_radius();
 426                 let radii = (node_r, node_r, node_r, node_r);
 427                 // Half the plate's roll: a node is far smaller than the pane,
 428                 // so the plate's full bevel width would eat most of the body —
 429                 // a tighter lip keeps the flat face reading.
 430                 let node_bevel = cce_ui::colors::plate_bevel_width() * 0.5;
 431                 let node_fill = cce_ui::colors::param_plate_fill();
 432                 // The pane material, with the nodes' own compression when
 433                 // configured (State::node_compression): the one knob that
 434                 // differs between a node body and the plate it sits on.
 435                 let node_mat = {
 436                     let mut m = cce_ui::scene::Material::from_fill(node_fill);
 437                     if let (Some(k), cce_ui::scene::Frost::Frosted { compression, .. }) = (self.node_compression, &mut m.frost) {
 438                         *compression = k;
 439                     }
 440                     m
 441                 };
 442                 let sel = cce_ui::colors::node_selected_color();
 443                 let drag = cce_ui::colors::node_drag_color();
 444                 let hl = cce_ui::colors::highlight_primary_color();
 445                 let hl_tint = [hl[0], hl[1], hl[2]];
 446                 let same_rgb = |a: [f32; 4], b: [f32; 4]| a[0] == b[0] && a[1] == b[1] && a[2] == b[2];
 447 
 448                 // Grid cells arrive tagged with their surviving corners and draw
 449                 // as superellipse tiles, like the desktop grid; everything else
 450                 // stays a flat quad. `g` is THIS pane's graph — the second
 451                 // editor paints its own widget's geometry through the same body.
 452                 let g: &dyn cce_ui::widget::GraphController =
 453                     if second { &*self.slots.content2 } else { self.graph() };
 454                 let cell_r = g.cell_corner_radius();
 455                 let mut bodies: Vec<(f32, f32, f32, f32, bool)> = Vec::new();
 456                 let mut overlays: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
 457                 let mut seen_node = false;
 458                 // The grid lines (flat, gap colour at the network opacity)
 459                 // and the origin axes, under the wires and nodes; the cells
 460                 // are the pane plate itself.
 461                 g.paint_grid(clip, pc);
 462                 for (qx, qy, qw, qh, qc, cell) in g.geometry_quads_tagged(clip) {
 463                     if g.is_node_rect(qx, qy, qw, qh) {
 464                         seen_node = true;
 465                         // An EXPANDED cursor selects every node standing
 466                         // inside it, and they wear the selected look. The
 467                         // widget colours one body — its own `selected_idx` —
 468                         // so the rest are recognised here, by the cell the
 469                         // body is centred on: the same `grid_cursor_covers`
 470                         // the selection itself is derived from, rather than a
 471                         // second rect test that could disagree with it. Pane
 472                         // 1 only, the grid cursor being pane 1's concept.
 473                         let in_region = !second
 474                             && self.grid_cursor_expanded()
 475                             && {
 476                                 let (col, row) = self.cell_at(qx + qw * 0.5, qy + qh * 0.5);
 477                                 self.grid_cursor_covers(col, row)
 478                             };
 479                         bodies.push((qx, qy, qw, qh, in_region || same_rgb(qc, sel) || same_rgb(qc, drag)));
 480                     } else if seen_node {
 481                         overlays.push((qx, qy, qw, qh, qc));
 482                     } else if let Some(corners) = cell {
 483                         pc.rounded_rect(rect(qx, qy, qw, qh), cell_r, corners, qc);
 484                     } else {
 485                         pc.quad(rect(qx, qy, qw, qh), qc);
 486                     }
 487                 }
 488                 // Drop-target glow, from the ANIMATED state (tick_frame owns
 489                 // it): position glides between cells, alpha fades in/out.
 490                 // One Prim::Glow — per-vertex-alpha rings the GPU
 491                 // interpolates, a genuinely smooth vignette (the stacked-rect
 492                 // version banded visibly). Drawn under the node bodies: with
 493                 // grid snap the glow reads as a soft aura around the dragged
 494                 // body.
 495                 if let Some(gl) = self.drop_glow {
 496                     if !second {
 497                         pc.glow(
 498                             rect(gl.x, gl.y, gl.w, gl.h),
 499                             cell_r,
 500                             30.0,
 501                             [1.0, 0.72, 0.80, 0.18 * gl.alpha],
 502                         );
 503                     }
 504                 }
 505                 for (qx, qy, qw, qh, highlighted) in bodies {
 506                     if highlighted {
 507                         pc.bevel_tinted(rect(qx, qy, qw, qh), radii, &node_mat, node_bevel, hl_tint);
 508                     } else {
 509                         pc.bevel(rect(qx, qy, qw, qh), radii, &node_mat, node_bevel);
 510                     }
 511                 }
 512                 for (qx, qy, qw, qh, qc) in overlays {
 513                     pc.quad(rect(qx, qy, qw, qh), qc);
 514                 }
 515             });
 516 
 517             for (cx, cy, cr, cc) in w.extra_circles() {
 518                 if cx >= clip.x && cx <= clip.x + clip.width && cy >= clip.y && cy <= clip.y + clip.height {
 519                     pc.circle(cx, cy, cr, cc);
 520                 }
 521             }
 522 
 523             if show_cursor && !second {
 524                 // A node-sized outline centred on the cursor's intersection —
 525                 // exactly where a node placed there would sit — or, after a
 526                 // drag across the grid, the union of the region it expanded
 527                 // over. One cell is the usual case and the same rect as ever.
 528                 let (cx, cy, cw, ch) = self.grid_cursor_rect();
 529                 // The cursor is the focus language: a FILL-LESS tinted plate
 530                 // (transparent bevel + accent tint), which the shader renders
 531                 // as the wrapped glint alone — the plate roll's own specular
 532                 // line, so it traces the SAME superellipse silhouette, radius
 533                 // family, and inset as the nodes and panes. Depth = the
 534                 // plates' bevel width for a matching band.
 535                 let color = colors::highlight_primary_color();
 536                 let tint = [color[0], color[1], color[2]];
 537                 let depth = cce_ui::colors::plate_bevel_width();
 538                 if self.graph().is_node_rect(cx, cy, cw, ch) {
 539                     let r = cce_ui::layout::graph_node_corner_radius();
 540                     pc.bevel_tinted(rect(cx, cy, cw, ch), (r, r, r, r), &cce_ui::scene::Material::from_fill([0.0; 4]), depth, tint);
 541                 } else {
 542                     let r = self.graph().cell_corner_radius();
 543                     pc.clip(clip, |pc| {
 544                         pc.bevel_tinted(rect(cx, cy, cw, ch), (r, r, r, r), &cce_ui::scene::Material::from_fill([0.0; 4]), depth, tint);
 545                     });
 546                 }
 547             }
 548         } else if idx == PARAM_IDX {
 549             // Modern-paint pane: ParametersBg::paint_ui (via paint_self)
 550             // authors the complete row chrome — wells, arcs, reliefs, throat
 551             // fillets, thumb spheres, scene rows, flat quads — plus the
 552             // fonted labels from the own-labels bridge, so append_frame_text
 553             // skips this slot. The pane keeps three designer-owned pieces:
 554             // the plate (span-widened radii + focus tint), the viewport clip
 555             // (a clip inside the widget would not survive paint_self's
 556             // replay), and the scrollbar straddle — idle it sinks behind the
 557             // translucent plate, active it rides above the content.
 558             let param_scrollbar = {
 559                 let pb = self
 560                     .slots
 561                     .param
 562                     .as_any()
 563                     .downcast_ref::<cce_ui::widget::ParametersBg>()
 564                     .expect("PARAM_IDX must be a ParametersBg");
 565                 if pb.scrollbar_visible() {
 566                     Some((pb.scrollbar_quads(), pb.scrollbar_active()))
 567                 } else {
 568                     None
 569                 }
 570             };
 571             // Track and thumb are pills — half-width radius on the DE corner
 572             // family (squircle when corner_shape > 2), like the nodes.
 573             if let Some((quads, false)) = &param_scrollbar {
 574                 for &(qx, qy, qw, qh, qc) in quads {
 575                     pc.rounded_rect(rect(qx, qy, qw, qh), qw.min(qh) * 0.5, (true, true, true, true), qc);
 576                 }
 577             }
 578 
 579             let (wx, wy, ww2, wh2) = w.rect();
 580             append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
 581 
 582             let (px, py, pw, ph) = self.positions[PARAM_IDX];
 583             let view = rect(px, py + 4.0, pw, (ph - 8.0).max(0.0));
 584             pc.clip(view, |pc| {
 585                 w.paint_self(&self.ui_context, pc);
 586             });
 587 
 588             // Expression rows carry Houdini's tint: a translucent green over
 589             // the row, so a driven parameter reads as driven before its text
 590             // is read. Rows are index-parallel to `param_display`, which is
 591             // what the pane was handed.
 592             if let Some(child) = self.param_editor_selected().and_then(|slot| self.param_editor_dir().children.get(slot)) {
 593                 let rows = crate::app::param_display(&child.params);
 594                 let is_expr = |key: &str| {
 595                     child.params.iter().any(|p| {
 596                         let k = if p.label.is_empty() { &p.name } else { &p.label };
 597                         k == key && p.expr
 598                     })
 599                 };
 600                 if rows.iter().any(|r| is_expr(&r.0)) {
 601                     let rects = self.param_row_rects();
 602                     pc.clip(view, |pc| {
 603                         for (row, &(rx, ry, rw, rh)) in rows.iter().zip(rects.iter()) {
 604                             if rh > 0.0 && is_expr(&row.0) {
 605                                 pc.rounded_rect(rect(rx, ry, rw, rh), 6.0, (true, true, true, true), [0.35, 0.8, 0.45, 0.16]);
 606                             }
 607                         }
 608                     });
 609                 }
 610             }
 611 
 612             if let Some((quads, true)) = &param_scrollbar {
 613                 for &(qx, qy, qw, qh, qc) in quads {
 614                     pc.rounded_rect(rect(qx, qy, qw, qh), qw.min(qh) * 0.5, (true, true, true, true), qc);
 615                 }
 616             }
 617         } else {
 618             let (wx, wy, ww2, wh2) = w.rect();
 619             let network_panel =
 620                 idx == NETWORK_PANEL_IDX || idx == crate::slots::NETWORK_PANEL2_IDX;
 621             if !(network_panel && !self.network_plate) {
 622                 append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
 623             }
 624 
 625             for (qx, qy, qw, qh, qc) in w.extra_quads() {
 626                 pc.quad(rect(qx, qy, qw, qh), qc);
 627             }
 628             for (cx, cy, cr, cc) in w.extra_circles() {
 629                 pc.circle(cx, cy, cr, cc);
 630             }
 631         }
 632 
 633         // Child elements, then the widget's popover on top of them.
 634         for child_ptr in self.ui_context.tree.children_ptrs(w.base().id()) {
 635             if let Some(child_idx) = self.find_widget_index(child_ptr as *const ()) {
 636                 self.paint_widget(child_idx, pc, show_cursor, visited, clip, clip_circle);
 637             } else {
 638                 unsafe {
 639                     self.paint_element(&*child_ptr, pc, show_cursor, visited, clip, clip_circle);
 640                 }
 641             }
 642         }
 643 
 644         if rounded_clip.is_some() {
 645             pc.pop_clip_rounded();
 646         }
 647         if active_circle.is_some() {
 648             pc.pop_clip_circle();
 649         }
 650     }
 651 
 652     fn paint_element(
 653         &self,
 654         element: &dyn WidgetHost,
 655         pc: &mut PaintCtx,
 656         show_cursor: bool,
 657         visited: &mut [bool],
 658         clip: Rect,
 659         clip_circle: Option<[f32; 3]>,
 660     ) {
 661         if !element.visible() {
 662             return;
 663         }
 664 
 665         if let Some(idx) = self.find_widget_index(element as *const dyn WidgetHost as *const ()) {
 666             self.paint_widget(idx, pc, show_cursor, visited, clip, clip_circle);
 667             return;
 668         }
 669 
 670         append_widget_plate(element, pc);
 671         for (qx, qy, qw, qh, qc) in element.extra_quads() {
 672             pc.quad(rect(qx, qy, qw, qh), qc);
 673         }
 674         for (cx, cy, cr, cc) in element.extra_circles() {
 675             pc.circle(cx, cy, cr, cc);
 676         }
 677 
 678         for child_ptr in self.ui_context.tree.children_ptrs(element.base().id()) {
 679             unsafe {
 680                 self.paint_element(&*child_ptr, pc, show_cursor, visited, clip, clip_circle);
 681             }
 682         }
 683     }
 684 
 685     /// Highlight border around the focused context's pane. The per-pane
 686     /// menubars are hidden in the floating layout, so this border is the
 687     /// only visual indicator of `focused_pane`.
 688     /// The focused pane's plate carries the highlight as its bevel's specular
 689     /// tint under `control_relief` — the ring in `append_context_border` is the
 690     /// flat-style treatment (the viewport's rim-only Boss overlay tints the
 691     /// same way). Only the circular pane (arc ring) keeps the ring in both
 692     /// styles.
 693     fn plate_focus_tint(&self, idx: usize) -> Option<[f32; 3]> {
 694         if !cce_ui::layout::control_relief() {
 695             return None;
 696         }
 697         let focused = match idx {
 698             NETWORK_PANEL_IDX | CONTENT_IDX => {
 699                 self.focused_pane == LEFT_MENUBAR_IDX && !self.circular_network_pane
 700             }
 701             // The second network editor shares the network focus domain —
 702             // whichever of the two is FRONTED wears the ring when it holds.
 703             crate::slots::NETWORK_PANEL2_IDX | crate::slots::CONTENT2_IDX => {
 704                 self.focused_pane == LEFT_MENUBAR_IDX
 705             }
 706             VIEWPORT_IDX => self.focused_pane == RIGHT_MENUBAR_IDX,
 707             PARAM_IDX => self.focused_pane == PARAM_MENUBAR_IDX,
 708             SPREADSHEET_IDX => self.focused_pane == SPREADSHEET_MENUBAR_IDX,
 709             _ => false,
 710         };
 711         focused.then(|| {
 712             let c = colors::highlight_primary_color();
 713             [c[0], c[1], c[2]]
 714         })
 715     }
 716 
 717     fn append_context_border(&self, pc: &mut PaintCtx) {
 718         if self.is_detached_network {
 719             return;
 720         }
 721         // Plated panes under control_relief mark focus through their bevel's
 722         // specular tint (plate_focus_tint) — no ring on top of it.
 723         let relief = cce_ui::layout::control_relief();
 724 
 725         let thickness = 2.0;
 726         let mut color = colors::highlight_primary_color();
 727         color[3] = 0.9;
 728 
 729         let (x, y, w, h) = match self.focused_pane {
 730             LEFT_MENUBAR_IDX => {
 731                 if !self.show_network || self.detached_circular_network {
 732                     return;
 733                 }
 734                 if self.circular_network_pane {
 735                     color[3] *= self.network_opacity;
 736                     pc.arc(
 737                         self.circular_network_layout.x,
 738                         self.circular_network_layout.y,
 739                         self.circular_network_layout.r,
 740                         3.0,
 741                         0.0,
 742                         TAU,
 743                         color,
 744                     );
 745                     return;
 746                 }
 747                 if relief {
 748                     return;
 749                 }
 750                 color[3] *= self.network_opacity;
 751                 // Whichever network editor is FRONTED owns the ring — pane
 752                 // 1's rect is zero while it waits as a tab.
 753                 let p2 = self.positions[crate::slots::NETWORK_PANEL2_IDX];
 754                 if p2.2 > 0.0 && self.positions[NETWORK_PANEL_IDX].2 <= 0.0 {
 755                     p2
 756                 } else {
 757                     self.positions[NETWORK_PANEL_IDX]
 758                 }
 759             }
 760             RIGHT_MENUBAR_IDX => {
 761                 if !self.show_viewport || relief {
 762                     return;
 763                 }
 764                 // The scene viewer's rim is the whole window root plate (see the
 765                 // VIEWPORT_IDX paint branch); its focus highlight follows it.
 766                 (0.0, 0.0, self.width, self.height)
 767             }
 768             PARAM_MENUBAR_IDX => {
 769                 if !self.show_parameters || relief {
 770                     return;
 771                 }
 772                 self.positions[PARAM_IDX]
 773             }
 774             SPREADSHEET_MENUBAR_IDX => {
 775                 if !self.show_spreadsheet || relief {
 776                     return;
 777                 }
 778                 self.positions[SPREADSHEET_IDX]
 779             }
 780             _ => return,
 781         };
 782 
 783         if w <= 0.0 || h <= 0.0 {
 784             return;
 785         }
 786         // The highlight follows the pane plate's arcs exactly: window-corner
 787         // corners at the window clip's curvature-matched span, interior
 788         // corners at the nominal plate radius (pane_plate_radii).
 789         pc.border(rect(x, y, w, h), self.pane_plate_radii(x, y, w, h), [0.0; 4], color, thickness);
 790     }
 791 
 792     /// The frame's text, as `Prim::Text` items shaped and drawn by the engine
 793     /// (`display_list_text`): each non-menubar widget's walk-derived labels — the
 794     /// graph's clamped to the network pane (and distance-filtered against the circular
 795     /// pane), network text fading with `network_opacity`. Popovers follow in
 796     /// `append_popovers`.
 797     fn append_frame_text(&self, pc: &mut PaintCtx) {
 798         let circular = self.circular_network_pane;
 799         let ncx = self.circular_network_layout.x;
 800         let ncy = self.circular_network_layout.y;
 801         let ncr = self.circular_network_layout.r;
 802 
 803         for i in 0..WIDGET_COUNT {
 804             let w = self.slots.get_dyn(i);
 805             if !w.visible() {
 806                 continue;
 807             }
 808             let is_menubar = i == HEADER_IDX || i == LEFT_MENUBAR_IDX || i == RIGHT_MENUBAR_IDX || i == PARAM_MENUBAR_IDX || i == SPREADSHEET_MENUBAR_IDX;
 809             // Panes on the paint_self path carry their text in the geometry
 810             // pass already (see paint_widget: subtree text for the playbar
 811             // and spreadsheet, the own-labels bridge for the params pane) —
 812             // drawing them here again would double it.
 813             if is_menubar
 814                 || i == PLAYBAR_IDX
 815                 || i == PARAM_IDX
 816                 || i == SPREADSHEET_IDX
 817                 || i == DIALOG_IDX
 818             {
 819                 continue;
 820             }
 821             let is_node = i == CONTENT_IDX;
 822             let is_network_part = i == CONTENT_IDX || i == BREADCRUMB_IDX || i == NETWORK_PANEL_IDX;
 823 
 824             // Widget-level clip bounds (logical px), matching the old TextBounds.
 825             let widget_bounds = if is_node {
 826                 if circular {
 827                     Some([ncx - ncr, ncy - ncr, ncx + ncr, ncy + ncr])
 828                 } else {
 829                     let (gx, gy, gw, gh) = self.positions[CONTENT_IDX];
 830                     Some([gx, gy, gx + gw, gy + gh])
 831                 }
 832             } else {
 833                 None
 834             };
 835 
 836             // Labels are plate children too — clip them at the plate's rounded corners.
 837             let rounded_clip = self.plate_rounded_clip(i);
 838             if let Some((rc, rr)) = rounded_clip {
 839                 pc.push_clip_rounded(rc, rr);
 840             }
 841             let mut scratch = PaintCtx::new();
 842             append_widget_text(&self.ui_context, w, &mut scratch);
 843             for item in scratch.finish().items {
 844                 if let Prim::Text { text, x, y, font_size, color, font, bounds: label_bounds, .. } = item.prim {
 845                     if circular && is_network_part {
 846                         let dx = x - ncx;
 847                         let dy = y - ncy;
 848                         if dx * dx + dy * dy > ncr * ncr {
 849                             continue;
 850                         }
 851                     }
 852                     // Node text belongs to the node domain: it fades with
 853                     // node_opacity, not the pane's network_opacity.
 854                     let alpha = if is_node {
 855                         self.node_opacity.clamp(0.0, 1.0)
 856                     } else if is_network_part {
 857                         self.network_opacity.clamp(0.0, 1.0)
 858                     } else {
 859                         1.0
 860                     };
 861                     pc.text_faded(text, x, y, font_size, color, alpha, font, merge_bounds(widget_bounds, label_bounds));
 862                 }
 863             }
 864             if rounded_clip.is_some() {
 865                 pc.pop_clip_rounded();
 866             }
 867         }
 868 
 869     }
 870 
 871     /// Open popovers (the params pane's expanded dropdowns), background then
 872     /// text per widget. Appended after `append_frame_text` so the popover
 873     /// occludes the widget labels underneath it — the display list is drawn
 874     /// strictly in order, so a popover background emitted in the geometry
 875     /// pass would sit under every label.
 876     /// The plates' corner menu triggers, drawn above pane content but below an
 877     /// open context menu: a solid dot in the plate's border color — one color,
 878     /// like the graph's port dots and geometry toggles — growing slightly on
 879     /// hover (and while its menu is open) instead of changing tint.
 880     /// The dock-drop highlight while a plate is being dragged by its dot: the
 881     /// region the release would snap it into, tinted and outlined.
 882     fn append_dock_drag_overlay(&self, pc: &mut PaintCtx) {
 883         let (Some(crate::app::AppDrag::DockDrag { .. }), Some(target)) = (self.app_drag, self.dock_drag_target) else {
 884             return;
 885         };
 886         let (x, y, w, h) = self.dock_rect(target);
 887         if w <= 0.0 || h <= 0.0 {
 888             return;
 889         }
 890         let hl = colors::highlight_primary_color();
 891         let r = cce_ui::layout::plate_corner_radius();
 892         pc.rounded_rect(rect(x, y, w, h), r, (true, true, true, true), [hl[0], hl[1], hl[2], 0.12]);
 893         pc.border(rect(x, y, w, h), (r, r, r, r), [0.0; 4], [hl[0], hl[1], hl[2], 0.8], 2.0);
 894     }
 895 
 896     fn append_plate_corners(&self, pc: &mut PaintCtx) {
 897         let hovered = self.plate_corner_at(self.cursor_x, self.cursor_y);
 898         for idx in crate::plate_corner::PLATE_SLOTS {
 899             let Some(c) = self.plate_corner_center(idx) else { continue };
 900             cce_ui::widget::plate_dock::draw_corner_dot(
 901                 pc,
 902                 c,
 903                 hovered == Some(idx) || self.plate_menu_slot == Some(idx),
 904             );
 905         }
 906     }
 907 
 908     /// The Alt+D dialog, last of the pane content: its plate and command list,
 909     /// its settings body, and that body's popovers.
 910     ///
 911     /// Out of the widget walk entirely, because the walk is not the end of the
 912     /// frame — `append_frame_text` and the viewport overlays follow it, and
 913     /// they drew the graph's node labels and the scale readout straight over
 914     /// a dialog whose z_index had already put it on top of the same panes.
 915     /// Only the context menu goes above this, and it can be opened from inside
 916     /// the dialog.
 917     fn append_dialog(
 918         &self,
 919         pc: &mut PaintCtx,
 920         show_cursor: bool,
 921         visited: &mut [bool],
 922         clip: Rect,
 923     ) {
 924         if !self.slots.dialog.visible() {
 925             return;
 926         }
 927         self.paint_widget(DIALOG_IDX, pc, show_cursor, visited, clip, None);
 928     }
 929 
 930     fn append_popovers(&self, pc: &mut PaintCtx) {
 931         for i in 0..WIDGET_COUNT {
 932             let w = self.slots.get_dyn(i);
 933             if !w.visible() {
 934                 continue;
 935             }
 936             let is_menubar = i == HEADER_IDX || i == LEFT_MENUBAR_IDX || i == RIGHT_MENUBAR_IDX || i == PARAM_MENUBAR_IDX || i == SPREADSHEET_MENUBAR_IDX;
 937             if is_menubar {
 938                 continue;
 939             }
 940             if self.focused_widget == Some(i) || i == PARAM_IDX {
 941                 let mut popover_pc = cce_ui::layout::PopoverCollector::new();
 942                 w.render_popover(&mut popover_pc);
 943                 for (color, px, py, pw, ph) in popover_pc.rects {
 944                     pc.quad(rect(px, py, pw, ph), color);
 945                 }
 946                 for (t, size, x, y, tc, font_opt, label_bounds) in popover_pc.texts {
 947                     let color = [
 948                         (tc[0] * 255.0).round().clamp(0.0, 255.0) as u8,
 949                         (tc[1] * 255.0).round().clamp(0.0, 255.0) as u8,
 950                         (tc[2] * 255.0).round().clamp(0.0, 255.0) as u8,
 951                     ];
 952                     pc.text_with(t, x, y, size, color, font_opt, label_bounds);
 953                 }
 954             }
 955         }
 956     }
 957 
 958     /// The meta "Point Numbers" overlay: each collected (position, index)
 959     /// label projects through the raster scene's cached mvp into 2D text,
 960     /// clipped to the viewport pane. The mvp cache refreshes whenever the
 961     /// camera or pane changes (`stage_frame`), so the labels track orbits;
 962     /// a frame staged before the first scene staging simply draws none.
 963     fn append_point_numbers(&self, pc: &mut PaintCtx) {
 964         if !self.show_viewport || self.overlay_number_labels.is_empty() {
 965             return;
 966         }
 967         let Some(mvp) = self.last_scene_mvp else { return };
 968         let (vx, vy, vw, vh) = self.last_scene_view_rect;
 969         if vw <= 0.0 || vh <= 0.0 {
 970             return;
 971         }
 972         pc.clip(rect(vx, vy, vw, vh), |pc| {
 973             for (pos, idx) in &self.overlay_number_labels {
 974                 let clip_pos = mvp * glam::Vec4::new(pos[0], pos[1], pos[2], 1.0);
 975                 if clip_pos.w <= 0.0 {
 976                     continue;
 977                 }
 978                 let ndc = clip_pos / clip_pos.w;
 979                 if ndc.x.abs() > 1.02 || ndc.y.abs() > 1.02 {
 980                     continue;
 981                 }
 982                 let sx = vx + (ndc.x * 0.5 + 0.5) * vw;
 983                 let sy = vy + (0.5 - ndc.y * 0.5) * vh;
 984                 pc.text(idx.to_string(), sx + 4.0, sy - 6.0, 10.0, [0xee, 0xee, 0xff]);
 985             }
 986         });
 987     }
 988 
 989     /// The view's scale on the pivot plane, bottom-left of the pane: `1:2.3`
 990     /// (the world shown at less than true size), `2.3:1` (magnified), or
 991     /// `1:1`, with what one world unit is and how long it shows. Marked when
 992     /// the display metric is only assumed — then the millimetres are the
 993     /// CSS 96 ppi guess, not a measurement.
 994     fn append_scale_readout(&self, pc: &mut PaintCtx) {
 995         if !self.show_viewport {
 996             return;
 997         }
 998         // The readout describes the 3D world's scale on screen. A page is not
 999         // in that world — it is a sheet of paper measured in inches — so over
1000         // a page the number is not merely irrelevant, it is wrong.
1001         if self.slots.page_view.image.is_some() {
1002             return;
1003         }
1004         let (vx, vy, vw, vh) = self.last_scene_view_rect;
1005         if vw <= 0.0 || vh <= 0.0 {
1006             return;
1007         }
1008         let r = self.view_scale_ratio();
1009         if !r.is_finite() || r <= 0.0 {
1010             return;
1011         }
1012         let ratio = if (r - 1.0).abs() < 0.01 {
1013             "1:1".to_string()
1014         } else if r > 1.0 {
1015             format!("1:{}", cce_ui::units::fmt_num((r * 100.0).round() / 100.0))
1016         } else {
1017             format!("{}:1", cce_ui::units::fmt_num((100.0 / r).round() / 100.0))
1018         };
1019         let m = cce_ui::units::metric();
1020         let shown_mm = self.world_unit_mm() / r;
1021         let mut text = format!("{ratio}  ·  1 {} = {} mm on screen", self.world_unit.suffix(), cce_ui::units::fmt_num((shown_mm * 100.0).round() / 100.0));
1022         if !m.is_real() {
1023             text.push_str("  ·  metric assumed");
1024         }
1025         pc.clip(rect(vx, vy, vw, vh), |pc| {
1026             pc.text(text, vx + 8.0, vy + vh - 16.0, 10.0, [0xaa, 0xaa, 0xbb]);
1027         });
1028     }
1029 
1030     /// The curve viewer state's handles: each control point projected
1031     /// through the cached scene mvp (like the point numbers above), drawn as
1032     /// a ringed dot with its index, the control cage as faint segments
1033     /// between them. Selected point draws larger and brighter.
1034     fn append_viewer_state_overlay(&self, pc: &mut PaintCtx) {
1035         let Some(tool) = &self.viewer_tool else { return };
1036         if !self.show_viewport {
1037             return;
1038         }
1039         let handles = self.viewer_tool_handles();
1040         let (vx, vy, vw, vh) = self.last_scene_view_rect;
1041         if vw <= 0.0 || vh <= 0.0 {
1042             return;
1043         }
1044         pc.clip(rect(vx, vy, vw, vh), |pc| {
1045             for pair in handles.windows(2) {
1046                 let (_, x0, y0, _) = pair[0];
1047                 let (_, x1, y1, _) = pair[1];
1048                 pc.vector(x0, y0, x1, y1, 1.0, [1.0, 1.0, 1.0, 0.25], cce_ui::scene::paint::Cap::Round);
1049             }
1050             for (i, sx, sy, _z) in &handles {
1051                 let selected = tool.selected == Some(*i);
1052                 let r = if selected { 6.0 } else { 4.5 };
1053                 // Dark ring behind for contrast against any scene.
1054                 pc.circle(*sx, *sy, r + 1.5, [0.0, 0.0, 0.0, 0.6]);
1055                 let col = if selected {
1056                     [1.0, 0.92, 0.55, 1.0]
1057                 } else {
1058                     [1.0, 0.78, 0.20, 1.0]
1059                 };
1060                 pc.circle(*sx, *sy, r, col);
1061                 pc.text(tool.source.handle_label(*i), sx + 8.0, sy - 6.0, 10.0, [0xff, 0xe6, 0xa0]);
1062             }
1063         });
1064 
1065         // The HUD sits one line ABOVE the scale readout, sharing its left
1066         // margin. Not at the top: the viewport is full-bleed and the pane
1067         // plates float over its top edge, so a mode line there lands under the
1068         // collapsed stubs and their titles read through it. Not at the very
1069         // bottom either — that row belongs to the scale readout, and two
1070         // sentences on one line read as one garbled sentence.
1071         //
1072         // It exists because a viewer state changes what every click does and
1073         // snapping silently changes what a drag does. A mode you cannot see is
1074         // a mode you forget you are in, and the first symptom is a click that
1075         // does something surprising.
1076         let hud = tool.hud();
1077         let size = 11.0;
1078         let pad = 5.0;
1079         let y = vy + vh - 16.0 - (size + pad * 2.0) - 4.0;
1080         let width = (hud.chars().count() as f32 * size * 0.52 + pad * 2.0).min(vw - 16.0);
1081         pc.clip(rect(vx, vy, vw, vh), |pc| {
1082             pc.quad(rect(vx + 8.0 - pad, y, width, size + pad * 2.0), [0.0, 0.0, 0.0, 0.55]);
1083             pc.text(hud, vx + 8.0, y + pad, size, [0xff, 0xe6, 0xa0]);
1084         });
1085     }
1086 
1087     /// Compose the 2D page the displayed level holds, if it holds one, and
1088     /// hand it to the page pane.
1089     ///
1090     /// The page context's counterpart to the geometry rebuild, and it runs on
1091     /// the same trigger for the same reason: a parameter changed, so what the
1092     /// pane shows is stale. The raster goes to the GPU as an image the widget
1093     /// only BORROWS — the id is owned here and freed when it is replaced, so a
1094     /// page that is being scrubbed does not leak a texture per frame.
1095     pub(crate) fn rebuild_page(&mut self) {
1096         let page = crate::page::displayed_page(&self.fs_root, self.viewport_editor_dir());
1097         if let Some(old) = self.page_image.take() {
1098             cce_ui::vk::free_image(old);
1099         }
1100         match page {
1101             Some(page) => {
1102                 let (w, h) = (page.width, page.height);
1103                 let id = cce_ui::vk::upload_rgba(page.to_rgba8(), w, h);
1104                 self.page_image = Some(id);
1105                 self.slots.page_view.set_image(Some((id, w, h)));
1106                 self.update_status_text(&format!(
1107                     "Page: {:.2} x {:.2} in at {} DPI ({}x{})",
1108                     page.size[0], page.size[1], page.dpi, w, h
1109                 ));
1110             }
1111             None => self.slots.page_view.set_image(None),
1112         }
1113         // Visibility and placement follow the image, and both are decided in
1114         // rebuild_positions.
1115         self.rebuild_positions();
1116         self.apply_layout();
1117     }
1118 
1119     pub(crate) fn rebuild_scene_geometry(&mut self) {
1120         let mut ocl_error = None;
1121         // The sim cache lives on State so playing forward steps each simnet once
1122         // per frame instead of re-solving its whole history every rebuild.
1123         let (frame, start) = (self.sim_frame(), self.sim_start_frame());
1124         let mut sim_cache = std::mem::take(&mut self.sim_cache);
1125         let geom = {
1126             let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
1127             // The viewport shows ITS editor's level — the pinned one when a
1128             // pin is set, else whichever editor took the last node click —
1129             // while name resolution stays rooted at fs_root. Navigation in
1130             // the bound editor re-scopes this.
1131             network_sphere_vertices_with_errors(&self.fs_root, self.viewport_editor_dir(), &mut ocl_error, &mut sim)
1132         };
1133         self.sim_cache = sim_cache;
1134 
1135         // A script error names its line; if the node it names is the one the
1136         // params pane shows, the pane flags that line in its code row. Cleared
1137         // whenever the evaluation says nothing about that node.
1138         let flagged = ocl_error.as_deref().and_then(|e| self.code_error_line_for_pane(e));
1139         self.slots.param_bg_mut().set_code_error_line(flagged);
1140         if let Some(e) = ocl_error {
1141             self.update_status_text(&format!("Node error: {}", e));
1142         } else {
1143             self.update_status_text("Geometry updated successfully.");
1144         }
1145 
1146         let verts = crate::geometry::detail_vertices(&geom);
1147         self.vertex_count_spheres = verts.len() as u32;
1148         // Smooth shading bakes the light into a raster copy of the fill;
1149         // `verts` stays unlit for the path tracer, whose materials are
1150         // these colours. Same triangles in the same order, so the count
1151         // above serves both.
1152         self.scene_smooth_verts =
1153             if self.smooth_shading { crate::geometry::smooth_lit_vertices(&geom) } else { Vec::new() };
1154         // Cache for the path tracer, so RT mode never re-runs the node
1155         // graph; the version bump invalidates its scene.
1156         // The raster mesh uploads from this same cache on the next
1157         // `stage_renderer` flush.
1158         self.rt_sphere_verts = verts;
1159         self.spheres_dirty = true;
1160         self.rt_geometry_version += 1;
1161         self.viewport_dirty = true;
1162 
1163         // The point overlays ride the same rebuild, off the same `geom`:
1164         // they annotate what is on screen, and what is on screen is exactly
1165         // this Detail.
1166         let (markers, labels, normals) = scene_point_overlays(
1167             &geom,
1168             self.show_point_markers,
1169             self.show_point_numbers,
1170             self.show_point_normals,
1171             self.point_marker_size,
1172             self.point_marker_color,
1173         );
1174         self.overlay_marker_verts = markers;
1175         self.overlay_number_labels = labels;
1176         self.overlay_normal_verts = normals;
1177         // The wire pass's edges, likewise — topological, and only while the
1178         // wireframe is actually on.
1179         self.scene_edge_verts =
1180             if self.wireframe { scene_edge_verts(&geom) } else { Vec::new() };
1181 
1182         // Visualize's vector markers ride the same LINE_LIST channel as the
1183         // normal whiskers.
1184         self.overlay_normal_verts.extend(crate::geometry::vis_marker_vertices(
1185             &geom,
1186             cce_ui::colors::to_linear_rgb,
1187         ));
1188         self.overlay_dirty = true;
1189 
1190         // Last, not first: the page's status line would otherwise be
1191         // overwritten by the geometry pass's own, and a level showing a page
1192         // has nothing to say about geometry.
1193         self.rebuild_page();
1194     }
1195 
1196     /// The path tracer's scene: the sphere geometry (and the reference cube if
1197     /// shown) as triangles, with one Lambertian material per distinct vertex
1198     /// color. Same mesh space as the raster pass, so the raster mvp's inverse
1199     /// drives the camera.
1200     pub(crate) fn collect_rt_scene(
1201         &self,
1202     ) -> (Vec<cce_ui::vk::RtTriangle>, Vec<cce_ui::vk::RtMaterial>) {
1203         let mut verts = self.rt_sphere_verts.clone();
1204         if self.viewport().show_cube {
1205             verts.extend(crate::geometry::cube_vertices());
1206         }
1207         crate::geometry::rt_scene_from_verts(&verts)
1208     }
1209 
1210     /// The 0-based line a node error points at in the params pane's selected
1211     /// node, when the error names that node and carries a `(line N` — the
1212     /// shape Rhai's diagnostics take (`wrangle1: point 4: ... (line 3,
1213     /// position 5)`). Anything else is None.
1214     pub(crate) fn code_error_line_for_pane(&self, error: &str) -> Option<usize> {
1215         let slot = self.param_editor_selected()?;
1216         let node_name = &self.param_editor_dir().children.get(slot)?.name;
1217         let rest = error.strip_prefix(node_name.as_str())?.strip_prefix(':')?;
1218         Self::error_line_number(rest)
1219     }
1220 
1221     /// A clipboard, selection or history action for the params pane's code
1222     /// editor, when one is open. False otherwise, so the caller's own
1223     /// handling runs.
1224     pub(crate) fn code_editor_action(&mut self, action: cce_ui::widget::ContextAction) -> bool {
1225         let pane = self.slots.param_bg_mut();
1226         pane.code_editing() && pane.code_action(action)
1227     }
1228 
1229     /// `(line N` anywhere in a diagnostic, 1-based in the text, 0-based out.
1230     pub(crate) fn error_line_number(text: &str) -> Option<usize> {
1231         let at = text.find("(line ")?;
1232         let digits: String = text[at + 6..].chars().take_while(|c| c.is_ascii_digit()).collect();
1233         digits.parse::<usize>().ok().filter(|n| *n >= 1).map(|n| n - 1)
1234     }
1235 
1236     pub(crate) fn update_status_text(&mut self, text: &str) {
1237         if self.last_status_text != text {
1238             self.last_status_text = text.to_string();
1239             self.slots.status.set_text(text);
1240         }
1241     }
1242 }
1243 
1244 /// The point overlays on the displayed scene: marker geometry for Show
1245 /// Point Markers, `(position, index)` labels for Show Point Numbers, and
1246 /// normal whiskers for Show Point Normals — each read straight off the
1247 /// merged scene `Detail` the geometry rebuild has already produced.
1248 ///
1249 /// It reads that one `Detail` rather than walking the tree because the
1250 /// overlays are a property of the VIEW, not of individual nodes. While they
1251 /// were per-node `meta` preferences this had to be a second walk that
1252 /// re-evaluated every flagged node on its own — the same repeated evaluation
1253 /// that made a five-flag Embryo cost 2.4 s an edit. The scene is evaluated
1254 /// once now, and the overlays cost a pass over its points.
1255 pub(crate) fn scene_point_overlays(
1256     geom: &crate::detail::Detail,
1257     markers_on: bool,
1258     numbers_on: bool,
1259     normals_on: bool,
1260     point_size: f32,
1261     marker_color: [f32; 3],
1262 ) -> (
1263     Vec<crate::geometry::Vertex3D>,
1264     Vec<([f32; 3], u32)>,
1265     Vec<crate::geometry::Vertex3D>,
1266 ) {
1267     let mut markers = Vec::new();
1268     let mut labels = Vec::new();
1269     let mut normals = Vec::new();
1270     if markers_on {
1271         // One marker per point. The soup emitted one per corner and leaned
1272         // on points_vertices deduping by position.
1273         let src: Vec<crate::geometry::Vertex3D> = geom
1274             .positions()
1275             .iter()
1276             .map(|&position| crate::geometry::Vertex3D { position, color: [0.0; 3] })
1277             .collect();
1278         markers.extend(crate::geometry::points_vertices(
1279             &src,
1280             point_size,
1281             cce_ui::colors::to_linear_rgb(marker_color),
1282         ));
1283     }
1284     if normals_on {
1285         // Smooth point normals: for each point, the normalized sum of the
1286         // face normals of the primitives touching it. Template meshes wind
1287         // CCW seen from outside (the raster culling convention), so the
1288         // plain cross(B-A, C-A) points outward. The kernel outputs' Norm
1289         // attribute is a default up-vector — useless here.
1290         //
1291         // The soup had to reconstruct "which triangles touch this point" by
1292         // hashing quantized positions, every frame. That was a weld in all
1293         // but name, and it is what point_prims answers directly.
1294         use glam::Vec3;
1295         let len = point_size * 4.0;
1296         let color = cce_ui::colors::to_linear_rgb([0.45, 0.8, 1.0]);
1297         for p in 0..geom.num_points() {
1298             let mut sum = Vec3::ZERO;
1299             for &prim in geom.point_prims(p) {
1300                 let pts = geom.prim_points(prim as usize);
1301                 if pts.len() < 3 {
1302                     continue;
1303                 }
1304                 let a = geom.pos(pts[0] as usize);
1305                 let b = geom.pos(pts[1] as usize);
1306                 let c = geom.pos(pts[2] as usize);
1307                 let n = (b - a).cross(c - a);
1308                 if n.length_squared() > 1e-12 {
1309                     sum += n;
1310                 }
1311             }
1312             let n = sum.normalize_or_zero();
1313             if n == Vec3::ZERO {
1314                 continue;
1315             }
1316             let pos = geom.positions()[p];
1317             let tip = geom.pos(p) + n * len;
1318             normals.push(crate::geometry::Vertex3D { position: pos, color });
1319             normals.push(crate::geometry::Vertex3D { position: tip.to_array(), color });
1320         }
1321     }
1322     if numbers_on {
1323         // The point's index, which is also its spreadsheet row. The soup
1324         // numbered by first-corner-at-this-position, so the overlay and the
1325         // spreadsheet disagreed.
1326         //
1327         // A dense mesh can label tens of thousands of points and the text
1328         // pass is per-frame, so cap it rather than melt the frame rate.
1329         const MAX_LABELS: usize = 2000;
1330         for p in 0..geom.num_points().min(MAX_LABELS) {
1331             labels.push((geom.positions()[p], p as u32));
1332         }
1333     }
1334     (markers, labels, normals)
1335 }
1336 
1337 /// The scene's own edges as LINE_LIST pairs for the wire pass, carrying the
1338 /// geometry's vertex colours.
1339 ///
1340 /// The TOPOLOGICAL edge list, not the triangle soup's: an edge two faces
1341 /// share is drawn once instead of twice, and a quad shows as a quad — the
1342 /// fan diagonal was never an edge of the mesh, only of its triangulation.
1343 /// The soup version was what the global Show Wireframe drew until
1344 /// 2026-09-23, while the per-node meta Wireframe drew this one; with the
1345 /// per-node flag retired there is one wireframe, and it is this one.
1346 pub(crate) fn scene_edge_verts(geom: &crate::detail::Detail) -> Vec<crate::geometry::Vertex3D> {
1347     let mut wires = Vec::new();
1348     for e in geom.edges() {
1349         for &p in e {
1350             let p = p as usize;
1351             wires.push(crate::geometry::Vertex3D {
1352                 position: geom.positions()[p],
1353                 color: geom.color(p),
1354             });
1355         }
1356     }
1357     wires
1358 }