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)) = ¶m_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)) = ¶m_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 }