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