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

src/render.rs (93.2K)

   1 
   2 use cce_ui::widget::WidgetHostExt;
   3 use cce_ui::colors;
   4 use cce_ui::widget::WidgetHost;
   5 
   6 use crate::app::State;
   7 use crate::slots::{
   8     WIDGET_COUNT,
   9     CONTENT_IDX, VIEWPORT_IDX, PARAM_IDX,
  10     BREADCRUMB_IDX, HEADER_IDX, RIGHT_MENUBAR_IDX,
  11     SPREADSHEET_MENUBAR_IDX, SPREADSHEET_IDX,
  12     LEFT_MENUBAR_IDX, PARAM_MENUBAR_IDX, NETWORK_PANEL_IDX, PLAYBAR_IDX,
  13     DIALOG_IDX, SPLITTER1_IDX, SPLITTER2_IDX,
  14 };
  15 use crate::geometry::network_sphere_vertices_with_errors;
  16 use cce_ui::scene::layout::Rect;
  17 use cce_ui::scene::paint::{DisplayList, PaintCtx, Prim};
  18 use cce_ui::scene::painter::{append_widget_plate, append_widget_plate_radii, append_widget_text};
  19 
  20 const TAU: f32 = 2.0 * std::f32::consts::PI;
  21 
  22 fn rect(x: f32, y: f32, w: f32, h: f32) -> Rect {
  23     Rect { x, y, width: w, height: h }
  24 }
  25 
  26 /// Intersect two logical `[l, t, r, b]` text bounds.
  27 fn merge_bounds(a: Option<[f32; 4]>, b: Option<[f32; 4]>) -> Option<[f32; 4]> {
  28     match (a, b) {
  29         (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])]),
  30         (Some(a), None) => Some(a),
  31         (None, b) => b,
  32     }
  33 }
  34 
  35 /// Whether focus is shown by tinting a roll (a plate's rim, the grid
  36 /// cursor's glint) rather than by a flat ring. It takes the relief AND the
  37 /// shader plates: the banded A/B path (`relief shader=false`) draws a tinted
  38 /// bevel untinted, so a pane that left its focus to the tint there showed
  39 /// none at all.
  40 fn focus_by_tint() -> bool {
  41     cce_ui::layout::control_relief() && cce_ui::layout::bevel_shader()
  42 }
  43 
  44 /// What a bypassed node wears in the network: Houdini's bypass flag is
  45 /// this colour, and nothing else in the pane is.
  46 const BYPASS_TINT: [f32; 3] = [1.0, 0.74, 0.18];
  47 
  48 /// The point numbers' font size, logical px.
  49 const POINT_NUMBER_PX: f32 = 10.0;
  50 /// The linear luminance above which a node name's floor takes dark ink
  51 /// (State::node_ink): where black and white text contrast it equally.
  52 const NODE_INK_CROSSOVER: f32 = 0.179;
  53 /// The dark ink a node name on a light floor is written in, sRGB.
  54 const NODE_DARK_INK: [u8; 3] = [0x1a, 0x1a, 0x22];
  55 
  56 
  57 impl State {
  58     /// Per-corner plate radii for a pane rect: a corner that sits ON a window
  59     /// corner is this pane's share of the window silhouette — the compositor
  60     /// clips the window at the span-widened root plate arc, so the pane wears
  61     /// that arc there (what a full-window root plate does in one piece).
  62     /// Interior corners keep the widget-scale nominal plate radius, matching
  63     /// the squircles of the sibling panes around them.
  64     /// The window's own edge, over the whole window: the scene viewer's lip
  65     /// (the VIEWPORT_IDX arm) and again over the playbar's shelf, which
  66     /// runs down into it.
  67     ///
  68     /// The 3D canvas is full-bleed (CANVAS_IDX covers the window; the other
  69     /// panes float over it), so the lip spans the WHOLE window with the
  70     /// window radius on all four corners (the SHARED silhouette value,
  71     /// concentric with the compositor clip). Under control_relief it is the
  72     /// fill-less ROLL OVERLAY (negative-depth Plate): exactly the roll other
  73     /// windows' root plates wear — same width, profile, crest and specular,
  74     /// full band inside the silhouette — screened over what is beneath,
  75     /// since a filled Plate would cover it (and a frosting fill would blur
  76     /// it). It replaced the Boss rim, whose boundary-straddling wall lost its
  77     /// outer half to the compositor clip: the visible band ran half a roll
  78     /// wide and started at mid-slope. Focus adds the fill-less tinted Bevel
  79     /// — the wrapped accent glint on the same silhouette, the network
  80     /// cursor's prim — matching the focused plates' treatment (shading
  81     /// unchanged, glint in accent). Without control_relief it degrades to
  82     /// the flat plate-border stroke, exactly a bordered plate's
  83     /// outline→relief degradation, and append_context_border owns the focus
  84     /// ring.
  85     fn append_window_lip(&self, pc: &mut PaintCtx) {
  86         let Some(bc) = cce_ui::colors::plate_border_color() else { return };
  87         let (px, py, pw, ph) = (0.0, 0.0, self.width, self.height);
  88         if pw <= 0.0 || ph <= 0.0 {
  89             return;
  90         }
  91         let vp_rect = rect(px, py, pw, ph);
  92         let radii = self.pane_plate_radii(px, py, pw, ph);
  93         if cce_ui::layout::control_relief() {
  94             // The window-edge roll width (style.surface.relief width), read
  95             // directly as the root plates of other windows read it. The
  96             // interior pane plates roll over the same number through
  97             // `plate_bevel_width` — one roll width since 2026-09-28; the
  98             // `plate.bevel_width` key that once set theirs apart is retired.
  99             let depth = cce_ui::layout::bevel_width();
 100             pc.plate_spec(&cce_ui::scene::paint::PlateSpec {
 101                 rect: vp_rect,
 102                 material: cce_ui::scene::Material::opaque([0.0; 4]),
 103                 window_corners: (true, true, true, true),
 104                 depth: -depth,
 105             });
 106             if let Some(tint) = self.plate_focus_tint(VIEWPORT_IDX) {
 107                 pc.bevel_tinted(vp_rect, radii, &cce_ui::scene::Material::from_fill([0.0; 4]), depth, tint);
 108             }
 109         } else {
 110             pc.border(vp_rect, radii, [0.0; 4], bc, cce_ui::colors::plate_border_thickness());
 111         }
 112     }
 113 
 114     fn pane_plate_radii(&self, x: f32, y: f32, w: f32, h: f32) -> (f32, f32, f32, f32) {
 115         // Delegates to the toolkit since RFC Phase 7b moved this math into
 116         // PlateSpec: window-corner arcs follow the SHARED silhouette curve
 117         // (never the app-merged plate override), interior corners the nominal
 118         // plate radius. This function was the reference implementation.
 119         use cce_ui::scene::paint::PlateSpec;
 120         let flags = PlateSpec::window_corner_flags(
 121             cce_ui::scene::layout::Rect { x, y, width: w, height: h },
 122             self.width,
 123             self.height,
 124         );
 125         PlateSpec::radii_for(flags)
 126     }
 127 
 128     /// The rounded-rect clip (plate rect + corner radius) a widget's content must stay
 129     /// inside, so children of plates cut off at the plate's rounded corners: the network
 130     /// plate for the network pane's parts (graph content, breadcrumb), the pane's own
 131     /// plate for the params and spreadsheet panes. `None` with square corners, and for
 132     /// the circular network pane (which clips by circle instead).
 133     fn plate_rounded_clip(&self, idx: usize) -> Option<(Rect, f32)> {
 134         let r = cce_ui::layout::plate_corner_radius();
 135         if r <= 0.0 {
 136             return None;
 137         }
 138         let (x, y, w, h) = match idx {
 139             CONTENT_IDX | BREADCRUMB_IDX if !self.circular_network_pane => {
 140                 self.positions[NETWORK_PANEL_IDX]
 141             }
 142             // The plate as drawn — fitted to the rows, the circle, or on
 143             // its way between them — so the rows are revealed as it grows.
 144             PARAM_IDX if self.params_plate => match self.params_plate_drawn() {
 145                 Some(([x, y, w, h, _], r)) => return (w > 0.0 && h > 0.0).then_some((rect(x, y, w, h), r)),
 146                 None => return None,
 147             },
 148             PARAM_IDX => self.positions[PARAM_IDX],
 149             SPREADSHEET_IDX => self.positions[SPREADSHEET_IDX],
 150             _ => return None,
 151         };
 152         if w <= 0.0 || h <= 0.0 {
 153             return None;
 154         }
 155         Some((rect(x, y, w, h), r))
 156     }
 157 
 158     /// The frame's entire 2D content — geometry AND text — as one display list (the
 159     /// engine's single paint path; `Application::display_list_text` opts the designer's
 160     /// text into the engine's shaping/glyph pass, so the app-side FontSystem and buffer
 161     /// cache are gone). Draw order is the hand-maintained slot order the vertex path
 162     /// used; the circular network pane rides `PaintItem::clip_circle`.
 163     pub(crate) fn collect_display_list(&mut self) -> DisplayList {
 164         // What is drawn over the scene is placed by the camera as it is
 165         // NOW, not as the stage pass last saw it.
 166         self.refresh_scene_view();
 167         // Refresh popover registration: the engine's text-occlusion clamp
 168         // reads `ui_context.active_popovers` to keep underlying text from
 169         // bleeding through an open popup's plate. The legacy render_widget
 170         // helper registered these as a side effect; the designer's own paint
 171         // walk must do it explicitly or open dropdowns get no occlusion.
 172         self.ui_context.clear_popovers();
 173         for i in 0..WIDGET_COUNT {
 174             if self.slots.get_dyn(&self.ui_context, i).visible() && self.slots.get_dyn(&self.ui_context, i).popover_rect().is_some() {
 175                 self.ui_context.register_popover_id(self.slots.id(i));
 176             }
 177         }
 178 
 179         let show_cursor = self.drag_widget.is_none() && self.app_drag.is_none();
 180 
 181         // The graph content clip (node quads, cursor) and the circular pane clip.
 182         let clip = if self.circular_network_pane {
 183             rect(
 184                 self.circular_network_layout.x - self.circular_network_layout.r,
 185                 self.circular_network_layout.y - self.circular_network_layout.r,
 186                 2.0 * self.circular_network_layout.r,
 187                 2.0 * self.circular_network_layout.r,
 188             )
 189         } else {
 190             let (cx, cy, cw, ch) = self.positions[CONTENT_IDX];
 191             rect(cx, cy, cw, ch)
 192         };
 193         let clip_circle = if self.circular_network_pane {
 194             Some([
 195                 self.circular_network_layout.x,
 196                 self.circular_network_layout.y,
 197                 self.circular_network_layout.r,
 198             ])
 199         } else {
 200             None
 201         };
 202 
 203         let mut draw_order: Vec<usize> = (0..WIDGET_COUNT).collect();
 204         draw_order.sort_by_key(|&i| {
 205             // The params HUD lives on the scene: right after the viewport
 206             // and before every plate, so each plate is drawn over it.
 207             let base_key = if i == VIEWPORT_IDX {
 208                 -7
 209             } else if i == PARAM_IDX {
 210                 -6
 211             } else if i == NETWORK_PANEL_IDX {
 212                 -5
 213             } else if i == CONTENT_IDX {
 214                 -4
 215             } else if i == HEADER_IDX
 216                 || i == LEFT_MENUBAR_IDX
 217                 || i == RIGHT_MENUBAR_IDX
 218                 || i == PARAM_MENUBAR_IDX
 219                 || i == SPREADSHEET_MENUBAR_IDX
 220             {
 221                 -3
 222             } else {
 223                 self.slots.get_dyn(&self.ui_context, i).z_index()
 224             };
 225             (self.has_any_open_menu(i), base_key)
 226         });
 227 
 228         // root plate container DISSOLVED (Phase 6as): register the widgets (registry consumers:
 229         // coverage/parent walks) and paint each top-level widget directly in sorted order.
 230         self.ui_context.clear_hierarchy();
 231         // Every slot stays registered through the wipe (the context owns them, and
 232         // `clear_hierarchy` keeps what it owns): the wheel loop and the hidden-widget
 233         // broadcasts dispatch by id over the whole roster, visible or not.
 234         let widget_ptrs: Vec<*mut (dyn WidgetHost + 'static)> = (0..WIDGET_COUNT)
 235             .map(|i| self.slots.get_dyn(&self.ui_context, i) as *const (dyn WidgetHost + 'static) as *mut (dyn WidgetHost + 'static))
 236             .collect();
 237         // The dialog's open dropdown, AFTER the dialog and after the wipe
 238         // above (which would drop it from the tree, leaving an id the
 239         // engine's clamp cannot resolve): the clamp lets an occluder's own
 240         // labels through only past the occluders registered before it, so
 241         // the dialog's labels under the list are clamped and the list's are
 242         // not.
 243         if self.dialog_visible() && self.ui_context[self.dialog_dd()].open && self.sync_dialog_dropdown() {
 244             // The dialog's dropdown is the dialog's own (an embedded widget), the context's
 245             // entry, registered as a popover by its id.
 246             let dd = self.dialog_dd();
 247             self.ui_context.register_popover_id(dd.id());
 248         }
 249 
 250         let mut pc = PaintCtx::new();
 251         let mut visited = vec![false; WIDGET_COUNT];
 252         for &i in &draw_order {
 253             if !self.slots.get_dyn(&self.ui_context, i).visible() {
 254                 continue;
 255             }
 256             // The dialog is painted after the overlay passes below, not in the
 257             // walk. A high z_index is not enough: `append_frame_text` and the
 258             // viewport overlays run AFTER the whole walk, so the graph's node
 259             // labels drew straight over a dialog that
 260             // had already covered them.
 261             if i == DIALOG_IDX {
 262                 continue;
 263             }
 264             unsafe {
 265                 self.paint_element(&*widget_ptrs[i], &mut pc, show_cursor, &mut visited, clip, clip_circle);
 266             }
 267         }
 268 
 269         self.append_context_border(&mut pc);
 270         self.append_frame_text(&mut pc);
 271         self.append_point_numbers(&mut pc);
 272         self.append_viewer_state_overlay(&mut pc);
 273         self.append_popovers(&mut pc);
 274         // Above every pane AND every overlay text pass, below only the context
 275         // menu — which can be opened from inside it.
 276         self.append_dialog(&mut pc, show_cursor, &mut visited, clip);
 277 
 278         // The context menu (every right-click menu, the plate menus
 279         // included — one shared state) floats above everything, drawn last as
 280         // the toolkit's lit plate: rounded, translucent, frosted — the
 281         // material every other floating surface wears. NOT the legacy
 282         // extra_quads loop, which is the square opaque pre-frost look.
 283         // Its labels carry bounds equal to the menu rect so the engine's text-
 284         // occlusion clamp (which registers the menu rect) exempts them.
 285         // One call for plate and labels: a TextLabel carries no family, so the
 286         // hand-rolled `paint` + `text_labels()` pair here passed None and drew
 287         // the menu in the default sans instead of the DE's menu font.
 288         cce_ui::widget::context_menu::paint_with_labels(&mut pc);
 289 
 290         pc.finish()
 291     }
 292 
 293     fn paint_widget(
 294         &self,
 295         idx: usize,
 296         pc: &mut PaintCtx,
 297         show_cursor: bool,
 298         visited: &mut [bool],
 299         clip: Rect,
 300         clip_circle: Option<[f32; 3]>,
 301     ) {
 302         if idx >= visited.len() || visited[idx] {
 303             return;
 304         }
 305         visited[idx] = true;
 306 
 307         let w = self.slots.get_dyn(&self.ui_context, idx);
 308         if !w.visible() {
 309             return;
 310         }
 311 
 312         // A collapsed pane is its title stub and nothing else: the plate and
 313         // the name. A press on it restores it; a right press, its plate menu.
 314         // Returning here is what suppresses the body — the params rows, the
 315         // spreadsheet grid, the transport controls — rather than relying on
 316         // each pane's own clip to hide content taller than the stub.
 317         if let Some(stub_label) = self.pane_stub_label(idx) {
 318             let (sx, sy, sw, sh) = w.rect();
 319             append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(sx, sy, sw, sh));
 320             let font_size = 12.0;
 321             let ty = cce_ui::layout::align_text_y(sy, sh, font_size, 0.0);
 322             // Bounds stop a margin short of the stub's end.
 323             let text_right = sx + sw - 12.0;
 324             pc.text_with(
 325                 stub_label,
 326                 sx + 12.0,
 327                 ty,
 328                 font_size,
 329                 [0xcc, 0xcc, 0xd4],
 330                 None,
 331                 Some([sx, sy, text_right, sy + sh]),
 332             );
 333             return;
 334         }
 335 
 336         let is_network_part = idx == CONTENT_IDX || idx == LEFT_MENUBAR_IDX || idx == BREADCRUMB_IDX || idx == NETWORK_PANEL_IDX;
 337         let active_circle = if is_network_part { clip_circle } else { None };
 338         if let Some(c) = active_circle {
 339             pc.push_clip_circle(c);
 340         }
 341 
 342         // Children of plates cut off at the plate's rounded corners (the param pane's
 343         // popovers escape this: they render later via `append_popovers`).
 344         let rounded_clip = self.plate_rounded_clip(idx);
 345         if let Some((rc, rr)) = rounded_clip {
 346             pc.push_clip_rounded(rc, rr);
 347         }
 348 
 349         if idx == NETWORK_PANEL_IDX && self.circular_network_pane {
 350             let cx = self.circular_network_layout.x;
 351             let cy = self.circular_network_layout.y;
 352             let r = self.circular_network_layout.r;
 353             // Circles carry no blur-behind marker (only Plate/Bevel prims do) — a
 354             // negative alpha from the params fill would render garbage, so clamp it.
 355             let mut fill = w.color();
 356             fill[3] = fill[3].abs();
 357             pc.circle(cx, cy, r, fill);
 358             pc.arc(cx, cy, r, 3.0, 0.0, TAU, [0.35, 0.65, 0.95, 0.80 * self.network_opacity]);
 359         } else if idx == DIALOG_IDX {
 360             // Modern-paint surface, the playbar's contract: the designer
 361             // authors the plate (the dialog floats, so the pane radii and the
 362             // focus tint do not apply — it is never a pane and never the
 363             // focused one), then Dialog::paint emits the query line and the
 364             // rows. A subtree painter, so append_frame_text skips the slot and
 365             // the chord column keeps its own font and bounds.
 366             //
 367             // The plate is THE MENU PLATE — `context_menu::paint_menu_plate`,
 368             // the one function the context menus draw theirs with: the
 369             // `style.surface.menu` material, radius and roll. Drawn here
 370             // rather than through `append_widget_plate` because that helper
 371             // builds a pane's material from its fill, and the dialog is not
 372             // a pane; it is a menu that happens to have a query line. Not in
 373             // a popup surface, so the in-app frost pass compresses its
 374             // backdrop as the config says rather than folding that into
 375             // opacity as the hosted menus must.
 376             let (x, y, ww, h) = w.rect();
 377             let full = rect(x, y, ww, h);
 378             match (self.ui_context[self.slots.dialog].turn_progress(), self.ui_context[self.slots.dialog].turning) {
 379                 (Some(e), Some(turn)) => {
 380                     // Turning (see `dialog::DialogTurn`): the plate on its way
 381                     // from the one it replaced, and what it shows sliding in
 382                     // and coming up inside it. Everything is painted aside
 383                     // and replayed moved; the text is cut at the plate as it
 384                     // is drawn, which is the occluder the dialog claims
 385                     // meanwhile, so the clamp still lets it through.
 386                     let now = self.ui_context[self.slots.dialog].drawn_rect(full);
 387                     cce_ui::widget::context_menu::paint_menu_plate(pc, now, false);
 388                     let mut scratch = PaintCtx::new();
 389                     w.paint_self(&self.ui_context, &mut scratch);
 390                     let dx = turn.dir * (1.0 - e) * cce_ui::widget::context_menu::TURN_SLIDE;
 391                     // Bounds are offset by the translate; these land on `now`.
 392                     let bounds = Some([now.x - dx, now.y, now.x + now.width - dx, now.y + now.height]);
 393                     let radius = cce_ui::layout::menu_corner_radius();
 394                     pc.clip_rounded(now, radius, |pc| {
 395                         pc.translate(dx, 0.0, |pc| {
 396                             for item in scratch.finish().items {
 397                                 if let Some(c) = item.clip {
 398                                     pc.push_clip(c);
 399                                 }
 400                                 // Everything comes up with the turn, as a
 401                                 // menu page's rows do (`Prim::faded`).
 402                                 if let Some(Prim::Text { text, x, y, font_size, color, alpha, font, .. }) = pc.replay(item.prim.faded(e * e)) {
 403                                     pc.text_faded(text, x, y, font_size, color, alpha, font, bounds);
 404                                 }
 405                                 if item.clip.is_some() {
 406                                     pc.pop_clip();
 407                                 }
 408                             }
 409                         });
 410                     });
 411                 }
 412                 _ => {
 413                     cce_ui::widget::context_menu::paint_menu_plate(pc, full, false);
 414                     w.paint_self(&self.ui_context, pc);
 415                 }
 416             }
 417         } else if idx == PLAYBAR_IDX {
 418             // Modern-paint pane: the plate from the legacy views like the other
 419             // panes, then paint_self emits the transport controls — geometry AND
 420             // text (a subtree painter; append_frame_text skips this slot so the
 421             // text isn't doubled).
 422             let (wx, wy, ww2, wh2) = w.rect();
 423             if self.ui_context[self.slots.playbar].inner().frame > 0.0 {
 424                 // A SHELF of the window's bottom edge, not a plate laid on
 425                 // it: a plate turned inside out (cce-ui's `frame`), whose
 426                 // face is everything below the scene's opening and whose
 427                 // rolled edge runs round the opening's outline. So the top
 428                 // edge meets each side lip in a COVE — the opening's bottom
 429                 // corner, at the plates' corner radius — one outline with
 430                 // the top, where a straight edge running into the lip
 431                 // crossed it and stacked the two rolls. The face runs out
 432                 // past the window's sides and bottom, so it has no other
 433                 // edge on screen, and the window's own lip is drawn again
 434                 // over the shelf and its coves: the side lips run down
 435                 // unbroken into the bottom corners, as they do around a
 436                 // window with no bar. The lip goes on AFTER the transport —
 437                 // a quad between a plate and its carves drops them to the
 438                 // overlay shading — which stands clear of it
 439                 // (`Playbar::frame`).
 440                 let e = cce_ui::colors::plate_bevel_width() + 1.0;
 441                 let cove = cce_ui::layout::plate_corner_radius().min(wh2);
 442                 let face = rect(wx - e, wy - cove, ww2 + 2.0 * e, wh2 + cove + e);
 443                 let opening = rect(wx, wy - self.height, ww2, self.height);
 444                 let material = cce_ui::scene::material::Material::from_fill(w.color());
 445                 let depth = if cce_ui::layout::control_relief() {
 446                     cce_ui::colors::plate_bevel_width()
 447                 } else {
 448                     0.0
 449                 };
 450                 pc.frame(face, opening, (0.0, 0.0, cove, cove), &material, depth);
 451                 w.paint_self(&self.ui_context, pc);
 452                 pc.clip(rect(wx, wy - cove, ww2, wh2 + cove), |pc| self.append_window_lip(pc));
 453             } else {
 454                 append_widget_plate_radii(w, pc, None, self.pane_plate_radii(wx, wy, ww2, wh2));
 455                 w.paint_self(&self.ui_context, pc);
 456             }
 457         } else if idx == BREADCRUMB_IDX {
 458             // Modern-paint control: Breadcrumb's whole look lives in its
 459             // Paint::paint() (the cce-ui restyle — per-segment plates on the
 460             // dropdown's relief, slanted seams) and it serves NO legacy views,
 461             // so the fall-through branch rendered bare labels on the network
 462             // plate. Unlike the playbar it is not a subtree painter: paint_self
 463             // drops the widget's Text prims, and the labels keep coming from
 464             // append_frame_text like every other slot — no doubling.
 465             w.paint_self(&self.ui_context, pc);
 466         } else if idx == SPREADSHEET_IDX {
 467             // Modern-paint pane: the plate from the designer (span-widened
 468             // radii + focus tint), then Spreadsheet::paint authors the grid —
 469             // header band, zebra rows, separators, dividers, and the fore
 470             // copy of its scrollbars. A subtree painter since cce-ui@f1cd939:
 471             // its text passes through paint_self verbatim, carrying the
 472             // per-column clamp bounds the own-labels bridge would drop;
 473             // append_frame_text skips the slot.
 474             let (wx, wy, ww2, wh2) = w.rect();
 475             // The scrollbar cross's idle copy, BEHIND the plate — the params
 476             // pane's straddle: the frosted plate dims it, and the widget
 477             // fades its fore copy in over the cells when a scroll raises it.
 478             // The idle copy fades OUT as the fore copy fades in: left at full
 479             // strength it showed through the plate under the raised bar and
 480             // the two stacked, so a raised bar read nearly opaque.
 481             let sheet = self.slots.spreadsheet(&self.ui_context);
 482             let sheet_rect = rect(wx, wy, ww2, wh2);
 483             if sheet.scrollbars_shown(sheet_rect) {
 484                 sheet.paint_scrollbars(sheet_rect, pc, 1.0 - sheet.scrollbar_fade());
 485             }
 486             append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
 487             w.paint_self(&self.ui_context, pc);
 488         } else if idx == VIEWPORT_IDX {
 489             // The scene viewer's lip is the window's own edge.
 490             self.append_window_lip(pc);
 491             w.paint_self(&self.ui_context, pc);
 492         } else if idx == CONTENT_IDX {
 493             // No plate here, and none behind: the network has no plate
 494             // (since 2026-10-06), its nodes standing on the scene. Until 2026-09-20 this arm ALSO painted
 495             // the graph widget's own background over it — the cell colour
 496             // at the network opacity, a flat blue-grey wash that predated
 497             // the plate and made the network pane the one pane with a hue.
 498 
 499             // A node's name hangs off its body onto whatever is behind the
 500             // pane, so with the plate faint it stands on the bare scene. With
 501             // `node_compression` set it gets a floor of the bodies' material,
 502             // as each params row does (`param.backdrop_compression`).
 503             let label_floors =
 504                 if self.node_compression.is_some() { self.node_label_floors(w) } else { Vec::new() };
 505 
 506             pc.clip(clip, |pc| {
 507                 // Node bodies wear the parameter plate's fill exactly — same
 508                 // tint, opacity, and blur-behind marker (param_plate_fill) —
 509                 // drawn as beveled mini-plates on the DE corner family. They
 510                 // draw as ONE consecutive run so the renderer's blur snapshot
 511                 // is shared across all of them (one full-frame copy, not one
 512                 // per node). Wires/grid/axes stay flat BEHIND the nodes; the
 513                 // geometry toggles stay flat ON TOP. A selected/dragged node
 514                 // keeps the identical fill but glints via a highlight specular
 515                 // tint on its bevel, so selection still reads.
 516                 let node_r = cce_ui::layout::graph_node_corner_radius();
 517                 let radii = (node_r, node_r, node_r, node_r);
 518                 // Half the plate's roll: a node is far smaller than the pane,
 519                 // so the plate's full bevel width would eat most of the body —
 520                 // a tighter lip keeps the flat face reading.
 521                 let node_bevel = cce_ui::colors::plate_bevel_width() * 0.5;
 522                 let node_mat = self.node_material();
 523                 let sel = cce_ui::colors::node_selected_color();
 524                 let drag = cce_ui::colors::node_drag_color();
 525                 let hl = cce_ui::colors::highlight_primary_color();
 526                 let hl_tint = [hl[0], hl[1], hl[2]];
 527                 let same_rgb = |a: [f32; 4], b: [f32; 4]| a[0] == b[0] && a[1] == b[1] && a[2] == b[2];
 528 
 529                 // Grid cells arrive tagged with their surviving corners and draw
 530                 // as superellipse tiles, like the desktop grid; everything else
 531                 // stays a flat quad.
 532                 let g: &dyn cce_ui::widget::GraphController = self.graph();
 533                 let cell_r = g.cell_corner_radius();
 534                 // Which of this pane's nodes are bypassed, by slot: the
 535                 // widget knows a node's rect and its slot, the tree knows
 536                 // the flag.
 537                 let bypassed: Vec<bool> = {
 538                     let level = self.current_dir();
 539                     level.children.iter().map(crate::geometry::is_bypassed).collect()
 540                 };
 541                 let mut bodies: Vec<(f32, f32, f32, f32, bool, bool)> = Vec::new();
 542                 let mut overlays: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
 543                 let mut seen_node = false;
 544                 // The wires, strokes in the node wire style and not among
 545                 // the quads, under the nodes. (The lattice's lines and its
 546                 // origin axes were drawn under them, by `paint_grid`, until
 547                 // 2026-10-07, when the grid's visual was removed; the lattice
 548                 // itself — where nodes stand, snapping, the grid cursor — is
 549                 // unchanged.)
 550                 g.paint_wires(clip, pc);
 551                 for (qx, qy, qw, qh, qc, cell) in g.geometry_quads_tagged(clip) {
 552                     if g.is_node_rect(qx, qy, qw, qh) {
 553                         seen_node = true;
 554                         // An EXPANDED cursor selects every node standing
 555                         // inside it, and they wear the selected look. The
 556                         // widget colours one body — its own `selected_idx` —
 557                         // so the rest are recognised here, by the cell the
 558                         // body is centred on: the same `grid_cursor_covers`
 559                         // the selection itself is derived from, rather than a
 560                         // second rect test that could disagree with it.
 561                         let in_region = self.grid_cursor_expanded()
 562                             && {
 563                                 let (col, row) = self.cell_at(qx + qw * 0.5, qy + qh * 0.5);
 564                                 self.grid_cursor_covers(col, row)
 565                             };
 566                         let off = g.node_at(qx + qw * 0.5, qy + qh * 0.5).is_some_and(|slot| bypassed.get(slot).copied().unwrap_or(false));
 567                         bodies.push((qx, qy, qw, qh, in_region || same_rgb(qc, sel) || same_rgb(qc, drag), off));
 568                     } else if seen_node {
 569                         overlays.push((qx, qy, qw, qh, qc));
 570                     } else if let Some(corners) = cell {
 571                         pc.rounded_rect(rect(qx, qy, qw, qh), cell_r, corners, qc);
 572                     } else {
 573                         pc.quad(rect(qx, qy, qw, qh), qc);
 574                     }
 575                 }
 576                 // Drop-target glow, from the ANIMATED state (tick_frame owns
 577                 // it): position glides between cells, alpha fades in/out.
 578                 // One Prim::Glow — per-vertex-alpha rings the GPU
 579                 // interpolates, a genuinely smooth vignette (the stacked-rect
 580                 // version banded visibly). Drawn under the node bodies: with
 581                 // grid snap the glow reads as a soft aura around the dragged
 582                 // body.
 583                 if let Some(gl) = self.drop_glow {
 584                     {
 585                         pc.glow(
 586                             rect(gl.x, gl.y, gl.w, gl.h),
 587                             cell_r,
 588                             30.0,
 589                             [1.0, 0.72, 0.80, 0.18 * gl.alpha],
 590                         );
 591                     }
 592                 }
 593                 // A bypassed node wears amber: its roll tinted, through the
 594                 // bevel's own tint channel, and a bar down its left side,
 595                 // flat and on top with the geometry toggles. The bar is
 596                 // what still says so while the node is selected and its
 597                 // roll is the selection's colour.
 598                 let mut bars: Vec<cce_ui::scene::layout::Rect> = Vec::new();
 599                 // In the bodies' run, so they share its blur snapshot.
 600                 for (floor, r) in &label_floors {
 601                     pc.fill_material(*floor, (*r, *r, *r, *r), &node_mat);
 602                 }
 603                 for (qx, qy, qw, qh, highlighted, off) in bodies {
 604                     if highlighted {
 605                         pc.bevel_tinted(rect(qx, qy, qw, qh), radii, &node_mat, node_bevel, hl_tint);
 606                     } else if off {
 607                         pc.bevel_tinted(rect(qx, qy, qw, qh), radii, &node_mat, node_bevel, BYPASS_TINT);
 608                     } else {
 609                         pc.bevel(rect(qx, qy, qw, qh), radii, &node_mat, node_bevel);
 610                     }
 611                     if off {
 612                         let (inset, wide) = (qh * 0.22, (qw * 0.05).max(2.0));
 613                         bars.push(rect(qx + inset, qy + inset, wide, qh - inset * 2.0));
 614                     }
 615                 }
 616                 for (qx, qy, qw, qh, qc) in overlays {
 617                     pc.quad(rect(qx, qy, qw, qh), qc);
 618                 }
 619                 for bar in bars {
 620                     pc.quad(bar, [BYPASS_TINT[0], BYPASS_TINT[1], BYPASS_TINT[2], 0.9]);
 621                 }
 622             });
 623 
 624             // The connector dots: the circles the graph paints (the rest of it is drawn
 625             // above, in this pane's own order).
 626             for prim in w.painted_prims() {
 627                 if let cce_ui::scene::paint::Prim::Circle { cx, cy, radius: cr, color: cc } = prim {
 628                     if cx >= clip.x && cx <= clip.x + clip.width && cy >= clip.y && cy <= clip.y + clip.height {
 629                         pc.circle(cx, cy, cr, cc);
 630                     }
 631                 }
 632             }
 633 
 634             if show_cursor {
 635                 // A node-sized outline centred on the cursor's intersection —
 636                 // exactly where a node placed there would sit — or, after a
 637                 // drag across the grid, the union of the region it expanded
 638                 // over. One cell is the usual case and the same rect as ever.
 639                 let (cx, cy, cw, ch) = self.grid_cursor_rect();
 640                 // The cursor is the focus language: a FILL-LESS tinted plate
 641                 // (transparent bevel + accent tint), which the shader renders
 642                 // as the wrapped glint alone — the plate roll's own specular
 643                 // line, so it traces the SAME superellipse silhouette, radius
 644                 // family, and inset as the nodes and panes. Depth = the
 645                 // plates' bevel width for a matching band.
 646                 //
 647                 // In the accent only while the network has focus, as a
 648                 // focused plate's rim is; otherwise in the plates' neutral
 649                 // border colour. Outside the circular pane this is the
 650                 // network's ONLY focus cue — it has no plate, no rim to tint.
 651                 // Without the shader plates the glint is drawn untinted, so
 652                 // focus adds a flat ring in the accent over it.
 653                 let focused = self.focused_pane == LEFT_MENUBAR_IDX;
 654                 let accent = colors::highlight_primary_color();
 655                 let tint = if focused {
 656                     [accent[0], accent[1], accent[2]]
 657                 } else {
 658                     // Never pure white: a white tint is the untinted plate,
 659                     // which a fill-less bevel draws as nothing.
 660                     let n = cce_ui::colors::plate_border_color().unwrap_or([0.58, 0.58, 0.66, 1.0]);
 661                     [n[0].min(0.99), n[1].min(0.99), n[2].min(0.99)]
 662                 };
 663                 let ring = (focused && !focus_by_tint()).then_some([accent[0], accent[1], accent[2], 0.9]);
 664                 let depth = cce_ui::colors::plate_bevel_width();
 665                 let paint_cursor = |pc: &mut PaintCtx, r: f32| {
 666                     let cr = rect(cx, cy, cw, ch);
 667                     pc.bevel_tinted(cr, (r, r, r, r), &cce_ui::scene::Material::from_fill([0.0; 4]), depth, tint);
 668                     if let Some(c) = ring {
 669                         pc.border(cr, (r, r, r, r), [0.0; 4], c, 2.0);
 670                     }
 671                 };
 672                 if self.graph().is_node_rect(cx, cy, cw, ch) {
 673                     paint_cursor(pc, cce_ui::layout::graph_node_corner_radius());
 674                 } else {
 675                     let r = self.graph().cell_corner_radius();
 676                     pc.clip(clip, |pc| paint_cursor(pc, r));
 677                 }
 678             }
 679         } else if idx == PARAM_IDX {
 680             // Modern-paint pane: ParametersBg::paint_ui (via paint_self)
 681             // authors the complete row chrome — wells, arcs, reliefs, throat
 682             // fillets, thumb spheres, scene rows, flat quads — plus the
 683             // fonted labels from the own-labels bridge, so append_frame_text
 684             // skips this slot. The pane keeps three designer-owned pieces:
 685             // the plate (span-widened radii + focus tint), the viewport clip
 686             // (a clip inside the widget would not survive paint_self's
 687             // replay), and the scrollbar straddle — its idle copy behind the
 688             // translucent plate every frame, its fore copy over the content
 689             // at the raise's fade, so a raise and a sink are a fade.
 690             let param_scrollbar = {
 691                 let pb = self.ui_context[self.slots.param]
 692                     .as_any()
 693                     .downcast_ref::<cce_ui::widget::ParametersBg>()
 694                     .expect("PARAM_IDX must be a ParametersBg");
 695                 if pb.scrollbar_visible() {
 696                     Some((pb.scrollbar_quads(), pb.scrollbar_fade()))
 697                 } else {
 698                     None
 699                 }
 700             };
 701             // Track and thumb are pills — half-width radius on the DE corner
 702             // family (squircle when corner_shape > 2), like the nodes.
 703             //
 704             // The plate (`params_plate`, on by default) is FITTED to the
 705             // rows (`params_claim`), not the HUD's rect, which runs the
 706             // viewport's height. Without it the rows stand on the scene,
 707             // and there is no idle copy of the bar, which only ever showed
 708             // faintly through the frost — the bar is seen when a scroll
 709             // raises it.
 710             //
 711             // With no rows to show it collapses into a small circle in the
 712             // HUD's top right corner, and grows back out of it: the plate is
 713             // drawn as `params_plate_drawn` has it, eased each tick, its
 714             // corners rounding from the pane's toward half its side.
 715             if let Some(([fx, fy, fw, fh, round], r)) = self.params_plate_drawn() {
 716                 if round < 0.5 {
 717                     // The idle copy fades out as the fore copy fades in, as
 718                     // the spreadsheet's does, so the two never stack.
 719                     if let Some((quads, fade)) = &param_scrollbar {
 720                         for &(qx, qy, qw, qh, mut qc) in quads {
 721                             qc[3] *= 1.0 - fade;
 722                             pc.rounded_rect(rect(qx, qy, qw, qh), qw.min(qh) * 0.5, (true, true, true, true), qc);
 723                         }
 724                     }
 725                 }
 726                 if round <= 0.0 {
 727                     // Settled on the rows: the plate every pane wears.
 728                     append_plate_at(w, pc, rect(fx, fy, fw, fh), self.plate_focus_tint(idx), self.pane_plate_radii(fx, fy, fw, fh));
 729                 } else {
 730                     // The circle, or on its way: the DE's corner exponent
 731                     // eased toward 2 — circular arcs, so a plate whose
 732                     // corners reach half its side IS a circle and not the
 733                     // squircle the rest of the DE wears — and the rolled
 734                     // edge eased toward a dot's, as cce-browser's bar
 735                     // unfolds from its corner control.
 736                     let cs = cce_ui::layout::corner_shape();
 737                     let shape = cs + (2.0 - cs) * round;
 738                     let depth = cce_ui::colors::plate_bevel_width() + (3.0 - cce_ui::colors::plate_bevel_width()) * round;
 739                     pc.plate_shaped(
 740                         rect(fx, fy, fw, fh),
 741                         (r, r, r, r),
 742                         &cce_ui::scene::material::Material::from_fill(w.color()),
 743                         depth.max(0.0),
 744                         Some(shape),
 745                     );
 746                 }
 747             }
 748 
 749             let (px, py, pw, ph) = self.positions[PARAM_IDX];
 750             let view = rect(px, py + 4.0, pw, (ph - 8.0).max(0.0));
 751             // The HUD is under every plate, and its geometry is drawn
 752             // before theirs, so they cover it — but the engine lays ALL
 753             // text out after all geometry, and a label under a plate would
 754             // stand on top of it. So the HUD is painted into what the
 755             // plates leave of it, a rect at a time (`uncovered`); one, most
 756             // of the time, and a handful at most.
 757             let pieces = uncovered(view, &self.plates_over_params());
 758             for &piece in &pieces {
 759                 pc.clip(piece, |pc| {
 760                     w.paint_self(&self.ui_context, pc);
 761                 });
 762             }
 763 
 764             // Expression rows carry Houdini's tint: a translucent green over
 765             // the row, so a driven parameter reads as driven before its text
 766             // is read. Rows are index-parallel to `param_display`, which is
 767             // what the pane was handed.
 768             if let Some(child) = self.param_editor_selected().and_then(|slot| self.param_editor_dir().children.get(slot)) {
 769                 let rows = crate::app::param_display(&child.params);
 770                 let is_expr = |key: &str| {
 771                     child.params.iter().any(|p| {
 772                         let k = p.shown_name();
 773                         k == key && p.is_expr()
 774                     })
 775                 };
 776                 if rows.iter().any(|r| is_expr(&r.0)) {
 777                     let rects = self.param_row_rects();
 778                     for &piece in &pieces {
 779                         pc.clip(piece, |pc| {
 780                             for (row, &(rx, ry, rw, rh)) in rows.iter().zip(rects.iter()) {
 781                                 if rh > 0.0 && is_expr(&row.0) {
 782                                     pc.rounded_rect(rect(rx, ry, rw, rh), 6.0, (true, true, true, true), [0.35, 0.8, 0.45, 0.16]);
 783                                 }
 784                             }
 785                         });
 786                     }
 787                 }
 788             }
 789 
 790             if let Some((quads, fade)) = &param_scrollbar {
 791                 if *fade > 0.001 {
 792                     for &(qx, qy, qw, qh, mut qc) in quads {
 793                         qc[3] *= fade;
 794                         pc.rounded_rect(rect(qx, qy, qw, qh), qw.min(qh) * 0.5, (true, true, true, true), qc);
 795                     }
 796                 }
 797             }
 798         } else {
 799             let (wx, wy, ww2, wh2) = w.rect();
 800             if idx != NETWORK_PANEL_IDX {
 801                 append_widget_plate_radii(w, pc, self.plate_focus_tint(idx), self.pane_plate_radii(wx, wy, ww2, wh2));
 802             }
 803             // A splitter IS its widget plate, lit in the splitter's colour; its own paint is
 804             // that colour again as a flat capsule, which would cover the plate's relief.
 805             if idx != SPLITTER1_IDX && idx != SPLITTER2_IDX {
 806                 w.paint_self(&self.ui_context, pc);
 807             }
 808         }
 809 
 810         // Child elements, then the widget's popover on top of them.
 811         for child_id in self.ui_context.tree.child_ids(w.base().id()) {
 812             if let Some(child_idx) = self.find_widget_index(child_id) {
 813                 self.paint_widget(child_idx, pc, show_cursor, visited, clip, clip_circle);
 814             } else if let Some(child) = self.ui_context.get_widget(child_id) {
 815                 self.paint_element(child, pc, show_cursor, visited, clip, clip_circle);
 816             }
 817         }
 818 
 819         if rounded_clip.is_some() {
 820             pc.pop_clip_rounded();
 821         }
 822         if active_circle.is_some() {
 823             pc.pop_clip_circle();
 824         }
 825     }
 826 
 827     fn paint_element(
 828         &self,
 829         element: &dyn WidgetHost,
 830         pc: &mut PaintCtx,
 831         show_cursor: bool,
 832         visited: &mut [bool],
 833         clip: Rect,
 834         clip_circle: Option<[f32; 3]>,
 835     ) {
 836         if !element.visible() {
 837             return;
 838         }
 839 
 840         if let Some(idx) = self.find_widget_index(element.base().id()) {
 841             self.paint_widget(idx, pc, show_cursor, visited, clip, clip_circle);
 842             return;
 843         }
 844 
 845         append_widget_plate(element, pc);
 846         element.paint_self(&self.ui_context, pc);
 847 
 848         for child_id in self.ui_context.tree.child_ids(element.base().id()) {
 849             if let Some(child) = self.ui_context.get_widget(child_id) {
 850                 self.paint_element(child, pc, show_cursor, visited, clip, clip_circle);
 851             }
 852         }
 853     }
 854 
 855     /// Highlight border around the focused context's pane. The per-pane
 856     /// menubars are hidden in the floating layout, so this border is the
 857     /// only visual indicator of `focused_pane`.
 858     /// The focused pane's plate carries the highlight as its bevel's specular
 859     /// tint while [`focus_by_tint`] — the ring in `append_context_border` is
 860     /// the treatment otherwise (the viewport's fill-less glint tints the same
 861     /// way). Only the circular pane (arc ring) keeps the ring in both styles.
 862     fn plate_focus_tint(&self, idx: usize) -> Option<[f32; 3]> {
 863         if !focus_by_tint() {
 864             return None;
 865         }
 866         let focused = match idx {
 867             NETWORK_PANEL_IDX => self.focused_pane == LEFT_MENUBAR_IDX && !self.circular_network_pane,
 868             VIEWPORT_IDX => self.focused_pane == RIGHT_MENUBAR_IDX,
 869             PARAM_IDX => self.focused_pane == PARAM_MENUBAR_IDX,
 870             SPREADSHEET_IDX => self.focused_pane == SPREADSHEET_MENUBAR_IDX,
 871             _ => false,
 872         };
 873         focused.then(|| {
 874             let c = colors::highlight_primary_color();
 875             [c[0], c[1], c[2]]
 876         })
 877     }
 878 
 879     fn append_context_border(&self, pc: &mut PaintCtx) {
 880         if self.is_detached_network {
 881             return;
 882         }
 883         // Plated panes mark focus through their bevel's specular tint
 884         // (plate_focus_tint) whenever the shader can draw one — no ring on
 885         // top of it.
 886         let relief = focus_by_tint();
 887 
 888         let thickness = 2.0;
 889         let mut color = colors::highlight_primary_color();
 890         color[3] = 0.9;
 891 
 892         let (x, y, w, h) = match self.focused_pane {
 893             LEFT_MENUBAR_IDX => {
 894                 if !self.show_network || self.detached_circular_network {
 895                     return;
 896                 }
 897                 if self.circular_network_pane {
 898                     color[3] *= self.network_opacity;
 899                     pc.arc(
 900                         self.circular_network_layout.x,
 901                         self.circular_network_layout.y,
 902                         self.circular_network_layout.r,
 903                         3.0,
 904                         0.0,
 905                         TAU,
 906                         color,
 907                     );
 908                     return;
 909                 }
 910                 // The network has no plate and spans the window, so there
 911                 // is no edge to ring: the grid cursor carries its focus.
 912                 return;
 913             }
 914             RIGHT_MENUBAR_IDX => {
 915                 if !self.show_viewport || relief {
 916                     return;
 917                 }
 918                 // The scene viewer's rim is the whole window root plate (see the
 919                 // VIEWPORT_IDX paint branch); its focus highlight follows it.
 920                 (0.0, 0.0, self.width, self.height)
 921             }
 922             PARAM_MENUBAR_IDX => {
 923                 // No plate, no edge to ring; the plate is fitted to the rows.
 924                 if !self.show_parameters || relief || !self.params_plate {
 925                     return;
 926                 }
 927                 self.params_claim()
 928             }
 929             SPREADSHEET_MENUBAR_IDX => {
 930                 if !self.show_spreadsheet || relief {
 931                     return;
 932                 }
 933                 self.positions[SPREADSHEET_IDX]
 934             }
 935             _ => return,
 936         };
 937 
 938         if w <= 0.0 || h <= 0.0 {
 939             return;
 940         }
 941         // The highlight follows the pane plate's arcs exactly: window-corner
 942         // corners at the window clip's curvature-matched span, interior
 943         // corners at the nominal plate radius (pane_plate_radii).
 944         pc.border(rect(x, y, w, h), self.pane_plate_radii(x, y, w, h), [0.0; 4], color, thickness);
 945     }
 946 
 947     /// The frame's text, as `Prim::Text` items shaped and drawn by the engine
 948     /// (`display_list_text`): each non-menubar widget's walk-derived labels — the
 949     /// graph's clamped to the network pane (and distance-filtered against the circular
 950     /// pane), network text fading with `network_opacity`. Popovers follow in
 951     /// `append_popovers`.
 952     /// A floor behind each node name a network editor draws, with its corner
 953     /// radius: the labels as the graph lays them out (read back from its
 954     /// text, so the left-hand flip is the widget's own), measured as
 955     /// shaped, padded a little. A name the circular pane drops in
 956     /// `append_frame_text` gets no floor either.
 957     fn node_label_floors(&self, w: &dyn WidgetHost) -> Vec<(Rect, f32)> {
 958         let (ncx, ncy, ncr) =
 959             (self.circular_network_layout.x, self.circular_network_layout.y, self.circular_network_layout.r);
 960         let mut scratch = PaintCtx::new();
 961         append_widget_text(&self.ui_context, w, &mut scratch);
 962         scratch
 963             .finish()
 964             .items
 965             .into_iter()
 966             .filter_map(|item| {
 967                 let Prim::Text { text, x, y, font_size, font, .. } = item.prim else { return None };
 968                 if self.circular_network_pane && (x - ncx).powi(2) + (y - ncy).powi(2) > ncr * ncr {
 969                     return None;
 970                 }
 971                 let family = match &font {
 972                     Some(f) => cce_ui::layout::split_font_string(f).0.to_string(),
 973                     None => cce_ui::layout::graph_node_font_parsed().0,
 974                 };
 975                 let tw = crate::dialog::shaped_width(&text, &family, font_size);
 976                 let (px, py) = (font_size * 0.4, font_size * 0.2);
 977                 let h = font_size + 2.0 * py;
 978                 let r = cce_ui::layout::control_corner_radius().min(h * 0.5);
 979                 Some((rect(x - px, y - py, tw + 2.0 * px, h), r))
 980             })
 981             .collect()
 982     }
 983 
 984     /// The node bodies' material: the pane material, with the nodes' own
 985     /// compression (State::node_compression) and tint (State::node_tint)
 986     /// where configured — the knobs that differ between a node body and the
 987     /// plate it sits on. The name floors wear it too.
 988     fn node_material(&self) -> cce_ui::scene::Material {
 989         let mut m = cce_ui::scene::Material::from_fill(cce_ui::colors::param_plate_fill());
 990         if let (Some(k), cce_ui::scene::Frost::Frosted { compression, .. }) = (self.node_compression, &mut m.frost) {
 991             *compression = k;
 992         }
 993         if let Some(t) = self.node_tint {
 994             m.tint = t;
 995         }
 996         m
 997     }
 998 
 999     /// The ink a node name is written in, where it is not the widget's own
1000     /// light grey: dark, when the name stands on a floor (node_compression
1001     /// set) whose key is light. Compression pulls the backdrop's luminance
1002     /// toward the tint's, and the tint is laid over that at its alpha, so
1003     /// the floor's luminance is the tint's to within the (1 - k) the
1004     /// compression leaves of the backdrop. 0.179 linear is where black and
1005     /// white ink have the same contrast against it. Without a floor a name
1006     /// stands on the bare scene, and stays light.
1007     pub(crate) fn node_ink(&self) -> Option<[u8; 3]> {
1008         self.node_compression?;
1009         let t = self.node_material().tint;
1010         let key = 0.2126 * t[0] + 0.7152 * t[1] + 0.0722 * t[2];
1011         (key > NODE_INK_CROSSOVER).then_some(NODE_DARK_INK)
1012     }
1013 
1014     fn append_frame_text(&self, pc: &mut PaintCtx) {
1015         let circular = self.circular_network_pane;
1016         let ncx = self.circular_network_layout.x;
1017         let ncy = self.circular_network_layout.y;
1018         let ncr = self.circular_network_layout.r;
1019 
1020         for i in 0..WIDGET_COUNT {
1021             let w = self.slots.get_dyn(&self.ui_context, i);
1022             if !w.visible() {
1023                 continue;
1024             }
1025             let is_menubar = i == HEADER_IDX || i == LEFT_MENUBAR_IDX || i == RIGHT_MENUBAR_IDX || i == PARAM_MENUBAR_IDX || i == SPREADSHEET_MENUBAR_IDX;
1026             // Panes on the paint_self path carry their text in the geometry
1027             // pass already (see paint_widget: subtree text for the playbar
1028             // and spreadsheet, the own-labels bridge for the params pane) —
1029             // drawing them here again would double it.
1030             if is_menubar
1031                 || i == PLAYBAR_IDX
1032                 || i == PARAM_IDX
1033                 || i == SPREADSHEET_IDX
1034                 || i == DIALOG_IDX
1035             {
1036                 continue;
1037             }
1038             let is_node = i == CONTENT_IDX;
1039             let is_network_part = i == CONTENT_IDX || i == BREADCRUMB_IDX || i == NETWORK_PANEL_IDX;
1040 
1041             // Widget-level clip bounds (logical px), matching the old TextBounds.
1042             let widget_bounds = if is_node {
1043                 if circular {
1044                     Some([ncx - ncr, ncy - ncr, ncx + ncr, ncy + ncr])
1045                 } else {
1046                     let (gx, gy, gw, gh) = self.positions[CONTENT_IDX];
1047                     Some([gx, gy, gx + gw, gy + gh])
1048                 }
1049             } else {
1050                 None
1051             };
1052 
1053             // Labels are plate children too — clip them at the plate's rounded corners.
1054             let rounded_clip = self.plate_rounded_clip(i);
1055             if let Some((rc, rr)) = rounded_clip {
1056                 pc.push_clip_rounded(rc, rr);
1057             }
1058             let mut scratch = PaintCtx::new();
1059             append_widget_text(&self.ui_context, w, &mut scratch);
1060             for item in scratch.finish().items {
1061                 if let Prim::Text { text, x, y, font_size, color, font, bounds: label_bounds, .. } = item.prim {
1062                     if circular && is_network_part {
1063                         let dx = x - ncx;
1064                         let dy = y - ncy;
1065                         if dx * dx + dy * dy > ncr * ncr {
1066                             continue;
1067                         }
1068                     }
1069                     // Node text belongs to the node domain: it fades with
1070                     // node_opacity, not the pane's network_opacity.
1071                     // A name on a light floor is written dark (node_ink).
1072                     let color = if is_node {
1073                         self.node_ink().unwrap_or(color)
1074                     } else {
1075                         color
1076                     };
1077                     let alpha = if is_node {
1078                         self.node_opacity.clamp(0.0, 1.0)
1079                     } else if is_network_part {
1080                         self.network_opacity.clamp(0.0, 1.0)
1081                     } else {
1082                         1.0
1083                     };
1084                     pc.text_faded(text, x, y, font_size, color, alpha, font, merge_bounds(widget_bounds, label_bounds));
1085                 }
1086             }
1087             if rounded_clip.is_some() {
1088                 pc.pop_clip_rounded();
1089             }
1090         }
1091 
1092     }
1093 
1094     /// The Alt+D dialog, last of the pane content: its plate and command list,
1095     /// its settings body, and that body's popovers.
1096     ///
1097     /// Out of the widget walk entirely, because the walk is not the end of the
1098     /// frame — `append_frame_text` and the viewport overlays follow it, and
1099     /// they drew the graph's node labels straight over
1100     /// a dialog whose z_index had already put it on top of the same panes.
1101     /// Only the context menu goes above this, and it can be opened from inside
1102     /// the dialog.
1103     fn append_dialog(
1104         &self,
1105         pc: &mut PaintCtx,
1106         show_cursor: bool,
1107         visited: &mut [bool],
1108         clip: Rect,
1109     ) {
1110         if !self.ui_context[self.slots.dialog].visible() {
1111             return;
1112         }
1113         self.paint_widget(DIALOG_IDX, pc, show_cursor, visited, clip, None);
1114     }
1115 
1116     /// Open popovers (the params pane's expanded dropdowns), background then
1117     /// text per widget, in the widget's own drawing. Appended after `append_frame_text` so the popover
1118     /// occludes the widget labels underneath it — the display list is drawn
1119     /// strictly in order, so a popover background emitted in the geometry
1120     /// pass would sit under every label.
1121     fn append_popovers(&self, pc: &mut PaintCtx) {
1122         for i in 0..WIDGET_COUNT {
1123             let w = self.slots.get_dyn(&self.ui_context, i);
1124             if !w.visible() {
1125                 continue;
1126             }
1127             let is_menubar = i == HEADER_IDX || i == LEFT_MENUBAR_IDX || i == RIGHT_MENUBAR_IDX || i == PARAM_MENUBAR_IDX || i == SPREADSHEET_MENUBAR_IDX;
1128             if is_menubar {
1129                 continue;
1130             }
1131             // Into the frame's own PaintCtx, as cce-files and the palette
1132             // paint theirs: a dropdown's expanded menu is its trigger's
1133             // plate grown, relief and corners and all. Until 2026-10-01 it
1134             // went through a `PopoverCollector`, which keeps fills as plain
1135             // rects — the menu came out square-cornered and flat, a
1136             // different thing from the control it grew out of.
1137             if self.focused_widget == Some(i) || i == PARAM_IDX {
1138                 w.render_popover(pc);
1139             }
1140         }
1141     }
1142 
1143     /// The meta "Point Numbers" overlay: each collected (position, index)
1144     /// label projects through the raster scene's cached mvp into 2D text,
1145     /// clipped to the viewport pane. The mvp cache refreshes whenever the
1146     /// camera or pane changes (`stage_frame`), so the labels track orbits;
1147     /// a frame staged before the first scene staging simply draws none.
1148     fn append_point_numbers(&self, pc: &mut PaintCtx) {
1149         let labels = self.point_number_labels();
1150         if labels.is_empty() {
1151             return;
1152         }
1153         let (vx, vy, vw, vh) = self.last_scene_view_rect;
1154         pc.clip(rect(vx, vy, vw, vh), |pc| {
1155             for (text, x, y, color, alpha) in labels {
1156                 pc.text_faded(text, x, y, POINT_NUMBER_PX, color, alpha, None, None);
1157             }
1158         });
1159     }
1160 
1161     /// The numbers as they are drawn: text, where, colour and strength.
1162     ///
1163     /// **A number under a plate is not drawn.** A plate frosts what is
1164     /// behind it, and the scene's markers and wires show through one
1165     /// blurred; but the engine lays ALL text out after all geometry, so a
1166     /// number under a plate would be drawn over it, sharp, where everything
1167     /// beside it is frosted. It cannot be blurred with the scene from here,
1168     /// and under the pane tint a blurred 10 px number would not be read
1169     /// anyway.
1170     pub(crate) fn point_number_labels(&self) -> Vec<(String, f32, f32, [u8; 3], f32)> {
1171         let mut out = Vec::new();
1172         if !self.show_viewport {
1173             return out;
1174         }
1175         // Points, primitives, vertices: each its own colour, and its own
1176         // nudge off the place it names — a point's number beside its
1177         // marker, the other two about on the spot, which is theirs alone.
1178         let lists: [(&[([f32; 3], u32)], &[f32], [u8; 3], (f32, f32)); 3] = [
1179             (&self.overlay_number_labels, &self.overlay_number_alpha, [0xee, 0xee, 0xff], (4.0, -6.0)),
1180             (&self.overlay_prim_labels, &self.overlay_prim_alpha, PRIM_LABEL_COLOR, (-4.0, -6.0)),
1181             (&self.overlay_vertex_labels, &self.overlay_vertex_alpha, VERTEX_LABEL_COLOR, (-3.0, -5.0)),
1182         ];
1183         if lists.iter().all(|(labels, ..)| labels.is_empty()) {
1184             return out;
1185         }
1186         let Some(mvp) = self.last_scene_mvp else { return out };
1187         let (vx, vy, vw, vh) = self.last_scene_view_rect;
1188         if vw <= 0.0 || vh <= 0.0 {
1189             return out;
1190         }
1191         {
1192             for (labels, alphas, color, (dx, dy)) in lists {
1193                 for (i, (pos, idx)) in labels.iter().enumerate() {
1194                     // What the fill in front of the place lets through; a
1195                     // number behind an opaque face is not drawn.
1196                     let alpha = alphas.get(i).copied().unwrap_or(1.0);
1197                     if alpha < 0.02 {
1198                         continue;
1199                     }
1200                     let clip_pos = mvp * glam::Vec4::new(pos[0], pos[1], pos[2], 1.0);
1201                     if clip_pos.w <= 0.0 {
1202                         continue;
1203                     }
1204                     let ndc = clip_pos / clip_pos.w;
1205                     if ndc.x.abs() > 1.02 || ndc.y.abs() > 1.02 {
1206                         continue;
1207                     }
1208                     let sx = vx + (ndc.x * 0.5 + 0.5) * vw;
1209                     let sy = vy + (0.5 - ndc.y * 0.5) * vh;
1210                     let text = idx.to_string();
1211                     // Both ends of the label, at about its middle height.
1212                     let (lx, ly) = (sx + dx, sy + dy);
1213                     let wide = text.len() as f32 * POINT_NUMBER_PX * 0.62;
1214                     let mid = ly + POINT_NUMBER_PX * 0.6;
1215                     if self.under_a_plate(lx, mid) || self.under_a_plate(lx + wide, mid) {
1216                         continue;
1217                     }
1218                     out.push((text, lx, ly, color, alpha));
1219                 }
1220             }
1221         }
1222         out
1223     }
1224 
1225     /// The curve viewer state's handles: each control point projected
1226     /// through the cached scene mvp (like the point numbers above), drawn as
1227     /// a ringed dot with its index, the control cage as faint segments
1228     /// between them. Selected point draws larger and brighter.
1229     /// Where the viewport's bottom-anchored text stands: the viewport's
1230     /// bottom, or the playbar's top while it is shown — the playbar is
1231     /// attached to the window's bottom edge, over the scene, and the
1232     /// viewer-state line would otherwise sit on its transport.
1233     pub(crate) fn scene_text_floor(&self, bottom: f32) -> f32 {
1234         let (_, py, pw, ph) = self.positions[crate::slots::PLAYBAR_IDX];
1235         if self.show_playbar && pw > 0.0 && ph > 0.0 {
1236             bottom.min(py)
1237         } else {
1238             bottom
1239         }
1240     }
1241 
1242     fn append_viewer_state_overlay(&self, pc: &mut PaintCtx) {
1243         let Some(tool) = &self.viewer_tool else { return };
1244         if !self.show_viewport {
1245             return;
1246         }
1247         let handles = self.viewer_tool_handles();
1248         let (vx, vy, vw, vh) = self.last_scene_view_rect;
1249         if vw <= 0.0 || vh <= 0.0 {
1250             return;
1251         }
1252         let outline = self.viewer_tool_outline();
1253         let cage = tool.source.cage();
1254         pc.clip(rect(vx, vy, vw, vh), |pc| {
1255             // What is being edited, under its handles: dark then light, so
1256             // the line reads over a white sheet and over a black one.
1257             for i in 0..outline.len() {
1258                 let (x0, y0) = outline[i];
1259                 let (x1, y1) = outline[(i + 1) % outline.len()];
1260                 pc.vector(x0, y0, x1, y1, 2.5, [0.0, 0.0, 0.0, 0.45], cce_ui::scene::paint::Cap::Round);
1261                 pc.vector(x0, y0, x1, y1, 1.0, [1.0, 0.78, 0.20, 0.9], cce_ui::scene::paint::Cap::Round);
1262             }
1263             for pair in handles.windows(2).filter(|_| cage) {
1264                 let (_, x0, y0, _) = pair[0];
1265                 let (_, x1, y1, _) = pair[1];
1266                 pc.vector(x0, y0, x1, y1, 1.0, [1.0, 1.0, 1.0, 0.25], cce_ui::scene::paint::Cap::Round);
1267             }
1268             for (i, sx, sy, _z) in &handles {
1269                 let selected = tool.selected == Some(*i);
1270                 let r = if selected { 6.0 } else { 4.5 };
1271                 // Dark ring behind for contrast against any scene.
1272                 pc.circle(*sx, *sy, r + 1.5, [0.0, 0.0, 0.0, 0.6]);
1273                 let col = if selected {
1274                     [1.0, 0.92, 0.55, 1.0]
1275                 } else {
1276                     [1.0, 0.78, 0.20, 1.0]
1277                 };
1278                 pc.circle(*sx, *sy, r, col);
1279                 // On a dark tab of its own, as the HUD is: a handle can
1280                 // stand over a white image, where light text is no text.
1281                 let label = tool.source.handle_label(*i);
1282                 if !label.is_empty() {
1283                     let width = label.chars().count() as f32 * 10.0 * 0.52 + 6.0;
1284                     pc.quad(rect(sx + 5.0, sy - 8.0, width, 14.0), [0.0, 0.0, 0.0, 0.55]);
1285                     pc.text(label, sx + 8.0, sy - 6.0, 10.0, [0xff, 0xe6, 0xa0]);
1286                 }
1287             }
1288         });
1289 
1290         // The HUD sits one line up from the viewport's bottom left, the row
1291         // it had above the scale readout, kept when the readout went
1292         // (2026-10-06). Not at the top: the viewport is full-bleed and the
1293         // pane plates float over its top edge, so a mode line there lands
1294         // under the collapsed stubs and their titles read through it.
1295         //
1296         // It exists because a viewer state changes what every click does and
1297         // snapping silently changes what a drag does. A mode you cannot see is
1298         // a mode you forget you are in, and the first symptom is a click that
1299         // does something surprising.
1300         let hud = tool.hud();
1301         let size = 11.0;
1302         let pad = 5.0;
1303         let y = self.scene_text_floor(vy + vh) - 16.0 - (size + pad * 2.0) - 4.0;
1304         let width = (hud.chars().count() as f32 * size * 0.52 + pad * 2.0).min(vw - 16.0);
1305         pc.clip(rect(vx, vy, vw, vh), |pc| {
1306             pc.quad(rect(vx + 8.0 - pad, y, width, size + pad * 2.0), [0.0, 0.0, 0.0, 0.55]);
1307             pc.text(hud, vx + 8.0, y + pad, size, [0xff, 0xe6, 0xa0]);
1308         });
1309     }
1310 
1311     /// Compose the 2D page the displayed level holds, if it holds one, and
1312     /// hand it to the scene as an image.
1313     ///
1314     /// The page context's counterpart to the geometry rebuild, and it runs on
1315     /// the same trigger for the same reason: a parameter changed, so what the
1316     /// viewport shows is stale. The raster goes to the GPU as an image whose
1317     /// id is owned here and freed when it is replaced, so a page that is
1318     /// being scrubbed does not leak a texture per frame. The stage pass draws
1319     /// it as a quad standing in the scene (`stage_frame`), where until
1320     /// 2026-09-29 a pane of its own took the viewport's place.
1321     pub(crate) fn rebuild_page(&mut self) {
1322         let key = crate::page::displayed_page_node(self.viewport_editor_dir())
1323             .map(|n| crate::page::chain_key(&self.fs_root, n));
1324         // Nothing the page reads has changed, and its picture is still up:
1325         // say what it is again (the geometry pass just said something else)
1326         // and leave the image alone.
1327         if let (Some(key), Some((was, status)), Some(_), Some(_)) =
1328             (key, self.page_composed.as_ref(), self.page_image, self.page_shown.as_ref())
1329         {
1330             if key == *was {
1331                 let status = status.clone();
1332                 self.update_status_text(&status);
1333                 return;
1334             }
1335         }
1336         self.page_composed = None;
1337         let level = self.viewport_editor_dir();
1338         let node_id = crate::page::displayed_page_node(level).map(|n| n.id.clone());
1339         let page = crate::page::displayed_page(&self.fs_root, level);
1340         // The same picture with new contents keeps its image: a handle
1341         // being dragged recomposes the page on every motion, and freeing an
1342         // image waits for the device to go idle.
1343         let before = self.page_shown.take();
1344         let had_page = before.is_some();
1345         let same_size = |page: &crate::page::Page| {
1346             before.as_ref().is_some_and(|b| b.pixels == (page.width, page.height))
1347         };
1348         let old = self.page_image.take();
1349         let kept = old.filter(|_| page.as_ref().is_some_and(same_size));
1350         if let (Some(old), None) = (old, kept) {
1351             cce_ui::vk::free_image(old);
1352         }
1353         if let (Some(page), Some(node_id)) = (page, node_id) {
1354             let (w, h) = (page.width, page.height);
1355             self.page_image = Some(match kept {
1356                 Some(id) => {
1357                     cce_ui::vk::update_pixels(id, page.to_rgba8(), w, h, cce_ui::vk::PixelFormat::Rgba);
1358                     id
1359                 }
1360                 // Mipmapped: a sheet composed at 300 DPI is drawn at a fifth
1361                 // of its size in an ordinary pane, and its hairlines crawl.
1362                 None => cce_ui::vk::upload_rgba_mipmapped(page.to_rgba8(), w, h),
1363             });
1364             let status = format!(
1365                 "Image: {} x {} {} at {} DPI ({}x{} px)",
1366                 trim_number(page.in_unit(page.size[0])),
1367                 trim_number(page.in_unit(page.size[1])),
1368                 page.unit.label(),
1369                 page.dpi,
1370                 w,
1371                 h
1372             );
1373             self.update_status_text(&status);
1374             self.page_composed = key.map(|k| (k, status));
1375             self.page_shown = Some(crate::page::PageShown {
1376                 node_id,
1377                 size: page.size,
1378                 pixels: (w, h),
1379                 origin: page.origin,
1380             });
1381         }
1382         if had_page || self.page_shown.is_some() {
1383             self.viewport_dirty = true;
1384             // The path tracer's scene holds the image too.
1385             self.page_version += 1;
1386         }
1387     }
1388 
1389     pub(crate) fn rebuild_scene_geometry(&mut self) {
1390         let mut ocl_error = None;
1391         // The sim cache lives on State so playing forward steps each simnet once
1392         // per frame instead of re-solving its whole history every rebuild.
1393         let (frame, start) = (self.sim_frame(), self.sim_start_frame());
1394         let mut sim_cache = std::mem::take(&mut self.sim_cache);
1395         let geom = {
1396             let mut sim = crate::geometry::EvalSim::new(frame, start, &mut sim_cache);
1397             // The viewport shows the network editor's level, while name
1398             // resolution stays rooted at fs_root. Navigation re-scopes this.
1399             network_sphere_vertices_with_errors(&self.fs_root, self.viewport_editor_dir(), &mut ocl_error, &mut sim)
1400         };
1401         self.sim_cache = sim_cache;
1402 
1403         // A script error names its line; if the node it names is the one the
1404         // params pane shows, the pane flags that line in its code row. Cleared
1405         // whenever the evaluation says nothing about that node.
1406         let flagged = ocl_error.as_deref().and_then(|e| self.code_error_line_for_pane(e));
1407         self.slots.param_bg_mut(&mut self.ui_context).set_code_error_line(flagged);
1408         if let Some(e) = ocl_error {
1409             self.update_status_text(&format!("Node error: {}", e));
1410         } else {
1411             self.update_status_text("Geometry updated successfully.");
1412         }
1413 
1414         // What the visualizers are applied to, kept so an edit to one
1415         // re-presents this scene rather than running the graph again.
1416         self.scene_attributes = crate::visualizer::scene_attributes(&geom);
1417         self.present_scene(&geom);
1418         self.scene_base = Some(geom);
1419 
1420         // The pull arrows measure the selected node against this new
1421         // geometry version; a playing simnet reaches here every frame.
1422         self.sync_selection_readouts();
1423         self.sync_pull_arrows();
1424 
1425         // Last, not first: the page's status line would otherwise be
1426         // overwritten by the geometry pass's own, and a level showing a page
1427         // has nothing to say about geometry.
1428         self.rebuild_page();
1429     }
1430 
1431     /// Show the scene last evaluated again, under the visualizers as they
1432     /// now stand — what an edit to one runs. Nothing before the first
1433     /// evaluation.
1434     /// Read the scene's environment again (`crate::environment`), and when
1435     /// it moved, redraw: a smooth-shaded fill re-bakes its light from the
1436     /// scene as last evaluated, which evaluates nothing. Run from the tick,
1437     /// so a sun driven by `$F`, or an edit that rebuilt nothing, is seen.
1438     pub(crate) fn sync_environment(&mut self) -> bool {
1439         let env = crate::environment::Environment::of_scene(&self.fs_root, self.sim_frame());
1440         if env == self.environment {
1441             return false;
1442         }
1443         self.environment = env;
1444         if self.smooth_shading {
1445             self.revisualize();
1446         }
1447         self.viewport_dirty = true;
1448         true
1449     }
1450 
1451     pub(crate) fn revisualize(&mut self) {
1452         // Taken and put back, not cloned: `present_scene` only reads it.
1453         if let Some(base) = self.scene_base.take() {
1454             self.present_scene(&base);
1455             self.scene_base = Some(base);
1456         }
1457     }
1458 
1459     /// Everything the viewport draws of an evaluated scene: the visualizers
1460     /// applied to it, then its fill, its groups, its overlays and its edges.
1461     ///
1462     /// Borrowed: the visualizers are the only thing that writes to the
1463     /// scene, so only they cost a copy of it. Until 2026-10-06 every rebuild
1464     /// and every revisualize cloned the whole Detail to keep `scene_base`,
1465     /// visualizers or not (~1 ms at 100k prims).
1466     fn present_scene(&mut self, base: &crate::detail::Detail) {
1467         let visualized;
1468         let geom = if crate::visualizer::any_applies(&self.visualizers) {
1469             let mut g = base.clone();
1470             crate::visualizer::apply_all(&self.visualizers, &mut g);
1471             visualized = g;
1472             &visualized
1473         } else {
1474             base
1475         };
1476 
1477         let verts = crate::geometry::detail_vertices(&geom);
1478         self.vertex_count_spheres = verts.len() as u32;
1479         // Smooth shading bakes the light into a raster copy of the fill;
1480         // `verts` stays unlit for the path tracer, whose materials are
1481         // these colours. Same triangles in the same order, so the count
1482         // above serves both.
1483         // Lit by the environment's sun, which the flat shader is handed too.
1484         self.environment = crate::environment::Environment::of_scene(&self.fs_root, self.sim_frame());
1485         self.scene_smooth_verts = if self.smooth_shading {
1486             crate::geometry::smooth_lit_vertices(&geom, self.environment.sun_direction)
1487         } else {
1488             Vec::new()
1489         };
1490         // Cache for the path tracer, so RT mode never re-runs the node
1491         // graph; the version bump invalidates its scene.
1492         // The raster mesh uploads from this same cache on the next
1493         // `stage_renderer` flush.
1494         self.rt_sphere_verts = verts;
1495         self.spheres_dirty = true;
1496         self.rt_geometry_version += 1;
1497         self.viewport_dirty = true;
1498 
1499         // The scene's point groups, with where their members are: what the
1500         // Group Markers dialog lists, and what the marked ones' markers are
1501         // built from.
1502         self.scene_groups = geom
1503             .points()
1504             .group_names()
1505             .iter()
1506             .map(|g| (g.to_string(), geom.points().group_members(g).iter().map(|&p| geom.positions()[p as usize]).collect()))
1507             .collect();
1508         self.rebuild_marked_group_markers();
1509 
1510         // The point overlays ride the same rebuild, off the same `geom`:
1511         // they annotate what is on screen, and what is on screen is exactly
1512         // this Detail.
1513         let (markers, labels, normals) = scene_point_overlays(
1514             &geom,
1515             self.show_point_markers,
1516             self.show_point_numbers,
1517             self.show_point_normals,
1518             self.point_marker_size,
1519             self.point_marker_color,
1520         );
1521         self.overlay_marker_instances = markers;
1522         // Kept for re-sizing the markers without this evaluation — see
1523         // `State::rebuild_overlay_markers`.
1524         self.overlay_marker_points = if self.show_point_markers {
1525             geom.positions()
1526                 .iter()
1527                 .map(|&position| crate::geometry::Vertex3D { position, color: [0.0; 3] })
1528                 .collect()
1529         } else {
1530             Vec::new()
1531         };
1532         self.overlay_number_labels = labels;
1533         self.overlay_number_alpha.clear();
1534         self.overlay_normal_verts = normals;
1535         let elements = scene_element_overlays(
1536             &geom,
1537             ElementOverlays {
1538                 prim_numbers: self.show_prim_numbers,
1539                 prim_normals: self.show_prim_normals,
1540                 vertex_numbers: self.show_vertex_numbers,
1541                 vertex_markers: self.show_vertex_markers,
1542                 vertex_normals: self.show_vertex_normals,
1543             },
1544             self.point_marker_size * 4.0,
1545         );
1546         self.overlay_prim_labels = elements.prim_labels;
1547         self.overlay_prim_alpha.clear();
1548         self.overlay_vertex_labels = elements.vertex_labels;
1549         self.overlay_vertex_alpha.clear();
1550         self.overlay_normal_verts.extend(elements.normals);
1551         self.overlay_vertex_marker_points = elements.vertex_markers;
1552         self.vertex_marker_instances = crate::geometry::marker_instances(
1553             &self.overlay_vertex_marker_points,
1554             cce_ui::colors::to_linear_rgb(VERTEX_LABEL_COLOR.map(|c| c as f32 / 255.0)),
1555         );
1556         // The wire pass's edges, likewise — topological, and only while the
1557         // wireframe is actually on.
1558         self.scene_edge_verts =
1559             if self.wireframe { scene_edge_verts(&geom) } else { Vec::new() };
1560 
1561         // Visualize's vector markers — a node's or a visualizer's — ride the
1562         // same LINE_LIST channel as the normal whiskers.
1563         self.overlay_normal_verts.extend(crate::geometry::vis_marker_vertices(
1564             &geom,
1565             cce_ui::colors::to_linear_rgb,
1566         ));
1567         self.overlay_dirty = true;
1568         // The numbers' dimming, for the view the scene was last staged
1569         // from. The 2D frame is painted BEFORE the stage pass, which works
1570         // the dimming out: left cleared, the first frame after every
1571         // rebuild drew each number at full strength, those behind the
1572         // surface too, and a playing simulation rebuilds every frame — the
1573         // numbers flickered between shown and dimmed.
1574         if let Some(mvp) = self.last_scene_mvp {
1575             self.sync_point_number_alpha(mvp, self.last_scene_eye);
1576         }
1577     }
1578 
1579     /// The path tracer's scene: the scene geometry as triangles, with one
1580     /// Lambertian material per distinct vertex color. Same mesh space as the
1581     /// raster pass, so the raster mvp's inverse drives the camera.
1582     pub(crate) fn collect_rt_scene(
1583         &self,
1584     ) -> (Vec<cce_ui::vk::RtTriangle>, Vec<cce_ui::vk::RtMaterial>) {
1585         crate::geometry::rt_scene_from_verts(&self.rt_sphere_verts)
1586     }
1587 
1588     /// The 0-based line a node error points at in the params pane's selected
1589     /// node, when the error names that node and carries a `(line N` — the
1590     /// shape Rhai's diagnostics take (`wrangle1: point 4: ... (line 3,
1591     /// position 5)`). Anything else is None.
1592     pub(crate) fn code_error_line_for_pane(&self, error: &str) -> Option<usize> {
1593         let slot = self.param_editor_selected()?;
1594         let node_name = &self.param_editor_dir().children.get(slot)?.name;
1595         let rest = error.strip_prefix(node_name.as_str())?.strip_prefix(':')?;
1596         Self::error_line_number(rest)
1597     }
1598 
1599     /// A clipboard, selection or history action for the params pane's code
1600     /// editor, when one is open. False otherwise, so the caller's own
1601     /// handling runs.
1602     pub(crate) fn code_editor_action(&mut self, action: cce_ui::widget::ContextAction) -> bool {
1603         let pane = self.slots.param_bg_mut(&mut self.ui_context);
1604         pane.code_editing() && pane.code_action(action)
1605     }
1606 
1607     /// `(line N` anywhere in a diagnostic, 1-based in the text, 0-based out.
1608     pub(crate) fn error_line_number(text: &str) -> Option<usize> {
1609         let at = text.find("(line ")?;
1610         let digits: String = text[at + 6..].chars().take_while(|c| c.is_ascii_digit()).collect();
1611         digits.parse::<usize>().ok().filter(|n| *n >= 1).map(|n| n - 1)
1612     }
1613 
1614     pub(crate) fn update_status_text(&mut self, text: &str) {
1615         if self.last_status_text != text {
1616             self.last_status_text = text.to_string();
1617             self.ui_context[self.slots.status].set_text(text);
1618         }
1619     }
1620 }
1621 
1622 /// The point overlays on the displayed scene: marker INSTANCES for Show
1623 /// Point Markers (one a point, drawn over `geometry::marker_sphere`), `(position, index)` labels for Show Point Numbers, and
1624 /// normal whiskers for Show Point Normals — each read straight off the
1625 /// merged scene `Detail` the geometry rebuild has already produced.
1626 ///
1627 /// It reads that one `Detail` rather than walking the tree because the
1628 /// overlays are a property of the VIEW, not of individual nodes. While they
1629 /// were per-node `meta` preferences this had to be a second walk that
1630 /// re-evaluated every flagged node on its own — the same repeated evaluation
1631 /// that made a five-flag Embryo cost 2.4 s an edit. The scene is evaluated
1632 /// once now, and the overlays cost a pass over its points.
1633 pub(crate) fn scene_point_overlays(
1634     geom: &crate::detail::Detail,
1635     markers_on: bool,
1636     numbers_on: bool,
1637     normals_on: bool,
1638     point_size: f32,
1639     marker_color: [f32; 3],
1640 ) -> (
1641     Vec<crate::geometry::Vertex3D>,
1642     Vec<([f32; 3], u32)>,
1643     Vec<crate::geometry::Vertex3D>,
1644 ) {
1645     let mut markers = Vec::new();
1646     let mut labels = Vec::new();
1647     let mut normals = Vec::new();
1648     if markers_on {
1649         // One marker per point. The soup emitted one per corner and leaned
1650         // on the instances deduping by position.
1651         let src: Vec<crate::geometry::Vertex3D> = geom
1652             .positions()
1653             .iter()
1654             .map(|&position| crate::geometry::Vertex3D { position, color: [0.0; 3] })
1655             .collect();
1656         markers = crate::geometry::marker_instances(&src, cce_ui::colors::to_linear_rgb(marker_color));
1657     }
1658     if normals_on {
1659         // Smooth point normals: for each point, the normalized sum of the
1660         // face normals of the primitives touching it. Template meshes wind
1661         // CCW seen from outside (the raster culling convention), so the
1662         // plain cross(B-A, C-A) points outward. The kernel outputs' N
1663         // attribute is a default up-vector — useless here.
1664         //
1665         // The soup had to reconstruct "which triangles touch this point" by
1666         // hashing quantized positions, every frame. That was a weld in all
1667         // but name, and it is what point_prims answers directly.
1668         use glam::Vec3;
1669         let len = point_size * 4.0;
1670         let color = cce_ui::colors::to_linear_rgb([0.45, 0.8, 1.0]);
1671         for p in 0..geom.num_points() {
1672             let mut sum = Vec3::ZERO;
1673             for &prim in geom.point_prims(p) {
1674                 let pts = geom.prim_points(prim as usize);
1675                 if pts.len() < 3 {
1676                     continue;
1677                 }
1678                 let a = geom.pos(pts[0] as usize);
1679                 let b = geom.pos(pts[1] as usize);
1680                 let c = geom.pos(pts[2] as usize);
1681                 let n = (b - a).cross(c - a);
1682                 if n.length_squared() > 1e-12 {
1683                     sum += n;
1684                 }
1685             }
1686             let n = sum.normalize_or_zero();
1687             if n == Vec3::ZERO {
1688                 continue;
1689             }
1690             let pos = geom.positions()[p];
1691             let tip = geom.pos(p) + n * len;
1692             normals.push(crate::geometry::Vertex3D { position: pos, color });
1693             normals.push(crate::geometry::Vertex3D { position: tip.to_array(), color });
1694         }
1695     }
1696     if numbers_on {
1697         // The point's index, which is also its spreadsheet row. The soup
1698         // numbered by first-corner-at-this-position, so the overlay and the
1699         // spreadsheet disagreed.
1700         //
1701         // A dense mesh can label tens of thousands of points and the text
1702         // pass is per-frame, so cap it rather than melt the frame rate.
1703         const MAX_LABELS: usize = 2000;
1704         for p in 0..geom.num_points().min(MAX_LABELS) {
1705             labels.push((geom.positions()[p], p as u32));
1706         }
1707     }
1708     (markers, labels, normals)
1709 }
1710 
1711 /// The colours the primitive and vertex overlays are told from the points'
1712 /// by: warm for a primitive's number and its normal, green for a vertex's.
1713 pub(crate) const PRIM_LABEL_COLOR: [u8; 3] = [0xff, 0xc8, 0x8c];
1714 pub(crate) const VERTEX_LABEL_COLOR: [u8; 3] = [0xa0, 0xf0, 0xb0];
1715 
1716 /// How far from its point toward its primitive's centroid a vertex's
1717 /// number stands: far enough that the vertices sharing a point are apart,
1718 /// near enough to say which corner each is.
1719 pub(crate) const VERTEX_LABEL_INSET: f32 = 0.3;
1720 
1721 /// A vertex marker's radius as a fraction of Point Marker Size.
1722 pub(crate) const VERTEX_MARKER_SCALE: f32 = 0.6;
1723 
1724 /// Which of the primitive and vertex overlays to collect.
1725 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1726 pub(crate) struct ElementOverlays {
1727     pub prim_numbers: bool,
1728     pub prim_normals: bool,
1729     pub vertex_numbers: bool,
1730     pub vertex_markers: bool,
1731     pub vertex_normals: bool,
1732 }
1733 
1734 /// What `scene_element_overlays` collected: `(position, index)` labels for
1735 /// the two kinds of number, the places the vertex markers stand, and the
1736 /// LINE_LIST whiskers of both kinds of normal.
1737 #[derive(Debug, Clone, Default)]
1738 pub(crate) struct ElementOverlayGeometry {
1739     pub prim_labels: Vec<([f32; 3], u32)>,
1740     pub vertex_labels: Vec<([f32; 3], u32)>,
1741     pub vertex_markers: Vec<crate::geometry::Vertex3D>,
1742     pub normals: Vec<crate::geometry::Vertex3D>,
1743 }
1744 
1745 /// The overlays of the other two element classes, read off the same scene
1746 /// `Detail` as the points'. A PRIMITIVE's number stands at its centroid and
1747 /// its normal is a whisker from there, `len` long. A VERTEX stands inset
1748 /// from its point toward its primitive's centroid — its number, its marker
1749 /// and the foot of its normal all there — so the vertices that share a
1750 /// point are apart, each inside its own primitive.
1751 ///
1752 /// A vertex's number is its index in the detail, which is its row in the
1753 /// spreadsheet, as a point's is. A primitive's normal is Newell's, so a
1754 /// quad that is not quite flat still has one; a primitive of fewer than
1755 /// three points has a number and no normal. **A vertex's normal is its `N`
1756 /// attribute where the detail carries one on its vertices, and its
1757 /// primitive's normal where it does not** — which is what a vertex normal
1758 /// is for: the normal of this corner of this face, where a point's is the
1759 /// average over every face around it. So on a mesh with no vertex normals
1760 /// the whiskers show the faceting, a fan of them at every shared point.
1761 /// Each list of labels is capped as the points' is.
1762 pub(crate) fn scene_element_overlays(
1763     geom: &crate::detail::Detail,
1764     want: ElementOverlays,
1765     len: f32,
1766 ) -> ElementOverlayGeometry {
1767     use glam::Vec3;
1768     const MAX_LABELS: usize = 2000;
1769     let mut out = ElementOverlayGeometry::default();
1770     if want == ElementOverlays::default() {
1771         return out;
1772     }
1773     let to_linear = |c: [u8; 3]| cce_ui::colors::to_linear_rgb(c.map(|c| c as f32 / 255.0));
1774     let (prim_color, vertex_color) = (to_linear(PRIM_LABEL_COLOR), to_linear(VERTEX_LABEL_COLOR));
1775     let own_normals = crate::geometry::own_vertex_normals(geom);
1776     let by_vertex = want.vertex_numbers || want.vertex_markers || want.vertex_normals;
1777     for prim in 0..geom.num_prims() {
1778         let pts = geom.prim_points(prim);
1779         if pts.is_empty() {
1780             continue;
1781         }
1782         let at: Vec<Vec3> = pts.iter().map(|&p| geom.pos(p as usize)).collect();
1783         let centroid = at.iter().copied().sum::<Vec3>() / at.len() as f32;
1784         let normal = if (want.prim_normals || want.vertex_normals) && at.len() >= 3 {
1785             let mut n = Vec3::ZERO;
1786             for (k, &a) in at.iter().enumerate() {
1787                 let b = at[(k + 1) % at.len()];
1788                 n += Vec3::new((a.y - b.y) * (a.z + b.z), (a.z - b.z) * (a.x + b.x), (a.x - b.x) * (a.y + b.y));
1789             }
1790             n.normalize_or_zero()
1791         } else {
1792             Vec3::ZERO
1793         };
1794         let mut whisker = |from: Vec3, n: Vec3, len: f32, color: [f32; 3]| {
1795             if n != Vec3::ZERO {
1796                 out.normals.push(crate::geometry::Vertex3D { position: from.to_array(), color });
1797                 out.normals.push(crate::geometry::Vertex3D { position: (from + n * len).to_array(), color });
1798             }
1799         };
1800         if want.prim_normals {
1801             whisker(centroid, normal, len, prim_color);
1802         }
1803         if by_vertex {
1804             for (v, &p) in geom.prim_verts(prim).zip(&at) {
1805                 let stands = p.lerp(centroid, VERTEX_LABEL_INSET);
1806                 if want.vertex_normals {
1807                     let n = own_normals
1808                         .and_then(|n| n.get(v))
1809                         .map(|n| n.as_vec3().normalize_or_zero())
1810                         .filter(|n| *n != Vec3::ZERO)
1811                         .unwrap_or(normal);
1812                     whisker(stands, n, len * VERTEX_MARKER_SCALE, vertex_color);
1813                 }
1814                 if want.vertex_markers {
1815                     out.vertex_markers.push(crate::geometry::Vertex3D { position: stands.to_array(), color: [0.0; 3] });
1816                 }
1817                 if want.vertex_numbers && out.vertex_labels.len() < MAX_LABELS {
1818                     out.vertex_labels.push((stands.to_array(), v as u32));
1819                 }
1820             }
1821         }
1822         if want.prim_numbers && out.prim_labels.len() < MAX_LABELS {
1823             out.prim_labels.push((centroid.to_array(), prim as u32));
1824         }
1825     }
1826     out
1827 }
1828 
1829 /// The scene's own edges as LINE_LIST pairs for the wire pass, carrying the
1830 /// geometry's vertex colours.
1831 ///
1832 /// The TOPOLOGICAL edge list, not the triangle soup's: an edge two faces
1833 /// share is drawn once instead of twice, and a quad shows as a quad — the
1834 /// fan diagonal was never an edge of the mesh, only of its triangulation.
1835 /// The soup version was what the global Show Wireframe drew until
1836 /// 2026-09-23, while the per-node meta Wireframe drew this one; with the
1837 /// per-node flag retired there is one wireframe, and it is this one.
1838 ///
1839 /// The edges without the rest of the topology (`Detail::edge_list`) and the
1840 /// colours as one column: a playing simulation's wireframe is built every
1841 /// frame, and until 2026-10-07 each built a whole topology and looked the
1842 /// colour up by name at both ends of every edge — the largest part of a
1843 /// replayed frame at 57k points.
1844 pub(crate) fn scene_edge_verts(geom: &crate::detail::Detail) -> Vec<crate::geometry::Vertex3D> {
1845     use crate::geometry::Vertex3D;
1846     let edges = geom.edge_list();
1847     let colors = geom.point_colors();
1848     let positions = geom.positions();
1849     let vertex = |p: u32| {
1850         let p = p as usize;
1851         Vertex3D { position: positions[p], color: colors.get(p).copied().unwrap_or(crate::detail::DEFAULT_COLOR) }
1852     };
1853     // Written into one buffer a piece of edges a thread, each piece its own
1854     // stretch of it, so the result is the one a single thread writes. A
1855     // thread costs tens of microseconds to start, so a piece is at least
1856     // `PIECE` edges and a small mesh stays on one.
1857     const PIECE: usize = 16_384;
1858     let mut wires = vec![Vertex3D { position: [0.0; 3], color: [0.0; 3] }; edges.len() * 2];
1859     let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(edges.len() / PIECE).max(1);
1860     let per = edges.len().div_ceil(threads).max(1);
1861     let fill = |out: &mut [Vertex3D], edges: &[[u32; 2]]| {
1862         for (pair, e) in out.chunks_exact_mut(2).zip(edges) {
1863             pair[0] = vertex(e[0]);
1864             pair[1] = vertex(e[1]);
1865         }
1866     };
1867     if threads == 1 {
1868         fill(&mut wires, &edges);
1869     } else {
1870         std::thread::scope(|scope| {
1871             for (out, edges) in wires.chunks_mut(per * 2).zip(edges.chunks(per)) {
1872                 let fill = &fill;
1873                 scope.spawn(move || fill(out, edges));
1874             }
1875         });
1876     }
1877     wires
1878 }
1879 
1880 /// A length for a status line: two decimals, without the zeros a whole
1881 /// number of pixels would trail.
1882 fn trim_number(v: f32) -> String {
1883     let s = format!("{v:.2}");
1884     s.trim_end_matches('0').trim_end_matches('.').to_string()
1885 }
1886 
1887 /// What is left of `r` once `covers` are taken out of it, as rects that do
1888 /// not overlap: each cover splits every piece it meets into the bands
1889 /// above and below it and the strips beside it. Covers are taken as their
1890 /// bounding rects, so a plate's rounded corner hides the sliver it would
1891 /// have shown.
1892 pub(crate) fn uncovered(r: Rect, covers: &[(f32, f32, f32, f32)]) -> Vec<Rect> {
1893     let mut pieces = vec![r];
1894     for &(cx, cy, cw, ch) in covers {
1895         let (cx1, cy1) = (cx + cw, cy + ch);
1896         let mut next = Vec::with_capacity(pieces.len() + 3);
1897         for p in pieces {
1898             let (px1, py1) = (p.x + p.width, p.y + p.height);
1899             let (ix0, iy0, ix1, iy1) = (p.x.max(cx), p.y.max(cy), px1.min(cx1), py1.min(cy1));
1900             if ix0 >= ix1 || iy0 >= iy1 {
1901                 next.push(p);
1902                 continue;
1903             }
1904             if iy0 > p.y {
1905                 next.push(rect(p.x, p.y, p.width, iy0 - p.y));
1906             }
1907             if iy1 < py1 {
1908                 next.push(rect(p.x, iy1, p.width, py1 - iy1));
1909             }
1910             if ix0 > p.x {
1911                 next.push(rect(p.x, iy0, ix0 - p.x, iy1 - iy0));
1912             }
1913             if ix1 < px1 {
1914                 next.push(rect(ix1, iy0, px1 - ix1, iy1 - iy0));
1915             }
1916         }
1917         pieces = next;
1918     }
1919     pieces.retain(|p| p.width > 0.5 && p.height > 0.5);
1920     pieces
1921 }
1922 
1923 /// `append_widget_plate_radii` at `r` rather than the widget's own rect:
1924 /// the params HUD's plate is fitted to its rows, and the HUD's rect runs the
1925 /// viewport's height. The same material, relief and border as every pane.
1926 fn append_plate_at(w: &dyn WidgetHost, pc: &mut PaintCtx, r: Rect, tint: Option<[f32; 3]>, radii: (f32, f32, f32, f32)) {
1927     let tint = tint.unwrap_or([1.0, 1.0, 1.0]);
1928     let fill = cce_ui::scene::material::Material::from_fill(w.color());
1929     if let Some((border_color, thickness)) = w.solid_border() {
1930         if cce_ui::layout::control_relief() {
1931             pc.bevel_tinted(r, radii, &fill, cce_ui::colors::plate_bevel_width(), tint);
1932         } else {
1933             pc.border(r, radii, w.color(), border_color, thickness);
1934         }
1935     } else {
1936         pc.border(r, radii, w.color(), [0.0; 4], 0.0);
1937     }
1938 }