diff --git a/lightningbeam-ui/Cargo.lock b/lightningbeam-ui/Cargo.lock index 48238f1..7cc9d75 100644 --- a/lightningbeam-ui/Cargo.lock +++ b/lightningbeam-ui/Cargo.lock @@ -3468,7 +3468,7 @@ dependencies = [ [[package]] name = "lightningbeam-editor" -version = "1.0.1-alpha" +version = "1.0.2-alpha" dependencies = [ "beamdsp", "bytemuck", diff --git a/lightningbeam-ui/lightningbeam-core/src/gradient.rs b/lightningbeam-ui/lightningbeam-core/src/gradient.rs index b4d10c2..b2384d8 100644 --- a/lightningbeam-ui/lightningbeam-core/src/gradient.rs +++ b/lightningbeam-ui/lightningbeam-core/src/gradient.rs @@ -66,6 +66,18 @@ pub struct ShapeGradient { /// Ignored for Radial. pub angle: f32, pub extend: GradientExtend, + /// Explicit world-space start point set by the gradient drag tool. + /// For Linear: the start of the gradient axis. + /// For Radial: the center of the gradient circle. + /// When `None`, the renderer falls back to bbox-based computation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_world: Option<(f64, f64)>, + /// Explicit world-space end point set by the gradient drag tool. + /// For Linear: the end of the gradient axis. + /// For Radial: a point on the edge of the gradient circle (defines radius). + /// When `None`, the renderer falls back to bbox-based computation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_world: Option<(f64, f64)>, } impl Default for ShapeGradient { @@ -73,11 +85,13 @@ impl Default for ShapeGradient { Self { kind: GradientType::Linear, stops: vec![ - GradientStop { position: 0.0, color: ShapeColor::rgba(0, 0, 0, 255) }, - GradientStop { position: 1.0, color: ShapeColor::rgba(0, 0, 0, 0) }, + GradientStop { position: 0.0, color: ShapeColor::rgba(255, 255, 255, 255) }, + GradientStop { position: 1.0, color: ShapeColor::rgba(0, 0, 0, 255) }, ], angle: 0.0, extend: GradientExtend::Pad, + start_world: None, + end_world: None, } } } diff --git a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs index 977f274..5939b86 100644 --- a/lightningbeam-ui/lightningbeam-core/src/hit_test.rs +++ b/lightningbeam-ui/lightningbeam-core/src/hit_test.rs @@ -94,7 +94,7 @@ pub fn hit_test_layer( if face.deleted || i == 0 { continue; // skip unbounded face } - if face.fill_color.is_none() && face.image_fill.is_none() { + if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() { continue; } if face.outer_half_edge.is_none() { @@ -472,7 +472,7 @@ pub fn hit_test_vector_editing( if face.deleted || i == 0 { continue; } - if face.fill_color.is_none() && face.image_fill.is_none() { + if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() { continue; } if face.outer_half_edge.is_none() { diff --git a/lightningbeam-ui/lightningbeam-core/src/renderer.rs b/lightningbeam-ui/lightningbeam-core/src/renderer.rs index 0bf5cbb..645de2c 100644 --- a/lightningbeam-ui/lightningbeam-core/src/renderer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/renderer.rs @@ -1092,8 +1092,24 @@ pub fn render_dcel( if !filled { if let Some(ref grad) = face.gradient_fill { use kurbo::Rect; + use crate::gradient::GradientType; let bbox: Rect = vello::kurbo::Shape::bounding_box(&path); - let (start, end) = gradient_bbox_endpoints(grad.angle, bbox); + let (start, end) = match (grad.start_world, grad.end_world) { + (Some((sx, sy)), Some((ex, ey))) => match grad.kind { + GradientType::Linear => { + (kurbo::Point::new(sx, sy), kurbo::Point::new(ex, ey)) + } + GradientType::Radial => { + // start_world = center, end_world = edge point. + // to_peniko_brush uses midpoint(start, end) as center, + // so reflect the edge through the center to get the + // opposing diameter endpoint. + let opp = kurbo::Point::new(2.0 * sx - ex, 2.0 * sy - ey); + (opp, kurbo::Point::new(ex, ey)) + } + }, + _ => gradient_bbox_endpoints(grad.angle, bbox), + }; let brush = grad.to_peniko_brush(start, end, opacity_f32); scene.fill(fill_rule, base_transform, &brush, None, &path); filled = true; diff --git a/lightningbeam-ui/lightningbeam-core/src/tool.rs b/lightningbeam-ui/lightningbeam-core/src/tool.rs index c62ce9d..cde31d4 100644 --- a/lightningbeam-ui/lightningbeam-core/src/tool.rs +++ b/lightningbeam-ui/lightningbeam-core/src/tool.rs @@ -366,6 +366,7 @@ impl Tool { Tool::Rectangle, Tool::Ellipse, Tool::PaintBucket, + Tool::Gradient, Tool::Eyedropper, Tool::Line, Tool::Polygon, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs index b7da34d..2a1e2af 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs @@ -48,10 +48,14 @@ pub fn gradient_stop_editor( }); // ── Gradient bar + handles ──────────────────────────────────────────── - let bar_height = 22.0_f32; - let handle_h = 14.0_f32; + let bar_height = 22.0_f32; + let peak_h = 7.0_f32; // triangular roof height + let body_h = 12.0_f32; // rectangular body height + let handle_h = peak_h + body_h; + let body_half_w = 6.0_f32; + let right_pad = 10.0_f32; // keep rightmost stop clear of infopanel scrollbar let total_height = bar_height + handle_h + 4.0; - let avail_w = ui.available_width(); + let avail_w = ui.available_width() - right_pad; let (bar_rect, bar_resp) = ui.allocate_exact_size( Vec2::new(avail_w, total_height), @@ -68,21 +72,34 @@ pub fn gradient_stop_editor( // Draw checkerboard background (transparent indicator). draw_checker(&painter, bar); - // Draw gradient bar as N segments. - let seg = 128_usize; - for i in 0..seg { - let t0 = i as f32 / seg as f32; - let t1 = (i + 1) as f32 / seg as f32; - let t = (t0 + t1) * 0.5; - let [r, g, b, a] = gradient.eval(t); - let col = Color32::from_rgba_unmultiplied(r, g, b, a); - let x0 = bar.min.x + t0 * bar.width(); - let x1 = bar.min.x + t1 * bar.width(); - let seg_rect = Rect::from_min_max( - egui::pos2(x0, bar.min.y), - egui::pos2(x1, bar.max.y), - ); - painter.rect_filled(seg_rect, 0.0, col); + // Draw gradient bar as a mesh: one quad per stop-pair with vertex colours + // so the GPU interpolates linearly — no segmentation artefacts. + { + use egui::epaint::{Mesh, Vertex}; + let mut mesh = Mesh::default(); + let stops = &gradient.stops; + let color_at = |t: f32| -> Color32 { + let [r, g, b, a] = gradient.eval(t); + Color32::from_rgba_unmultiplied(r, g, b, a) + }; + // One quad for each consecutive stop pair. + for pair in stops.windows(2) { + let t0 = pair[0].position; + let t1 = pair[1].position; + let c0 = color_at(t0); + let c1 = color_at(t1); + let x0 = bar.min.x + t0 * bar.width(); + let x1 = bar.min.x + t1 * bar.width(); + let base = mesh.vertices.len() as u32; + mesh.vertices.extend_from_slice(&[ + Vertex { pos: egui::pos2(x0, bar.min.y), uv: egui::Pos2::ZERO, color: c0 }, + Vertex { pos: egui::pos2(x1, bar.min.y), uv: egui::Pos2::ZERO, color: c1 }, + Vertex { pos: egui::pos2(x1, bar.max.y), uv: egui::Pos2::ZERO, color: c1 }, + Vertex { pos: egui::pos2(x0, bar.max.y), uv: egui::Pos2::ZERO, color: c0 }, + ]); + mesh.indices.extend_from_slice(&[base, base+1, base+2, base, base+2, base+3]); + } + painter.add(egui::Shape::mesh(mesh)); } // Outline. painter.rect_stroke(bar, 2.0, Stroke::new(1.0, Color32::from_gray(60)), eframe::egui::StrokeKind::Middle); @@ -98,104 +115,159 @@ pub fn gradient_stop_editor( color: ShapeColor::rgba(r, g, b, a), }); gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); - *selected_stop = gradient.stops.iter().position(|s| s.position == t); + *selected_stop = gradient.stops.iter().position(|s| (s.position - t).abs() < 1e-5); changed = true; } } } - // Draw stop handles. - // We need to detect drags per-handle, so allocate individual rects with the - // regular egui input model. To avoid borrow conflicts we collect interactions - // before mutating. - let handle_w = 10.0_f32; - let n_stops = gradient.stops.len(); + // ── Stop handles: interact + popup ─────────────────────────────────── + let n_stops = gradient.stops.len(); - let mut drag_idx: Option = None; - let mut drag_delta: f32 = 0.0; - let mut click_idx: Option = None; - - // To render handles after collecting, remember their rects. + // Top-anchored hit rects (peak touches track.min.y). let handle_rects: Vec = (0..n_stops).map(|i| { let cx = track.min.x + gradient.stops[i].position * track.width(); - Rect::from_center_size( - egui::pos2(cx, track.center().y), - Vec2::new(handle_w, handle_h), + Rect::from_min_size( + egui::pos2(cx - body_half_w, track.min.y), + Vec2::new(body_half_w * 2.0, handle_h), ) }).collect(); + let mut drag_delta : f32 = 0.0; + let mut drag_active: bool = false; + let mut drag_ended : bool = false; + let mut delete_idx : Option = None; + for (i, &h_rect) in handle_rects.iter().enumerate() { let resp = ui.interact(h_rect, ui.id().with(("grad_handle", i)), Sense::click_and_drag()); + + // Anchor the dragged stop at drag-start time, before any sort can change indices. + if resp.drag_started() { + *selected_stop = Some(i); + } if resp.dragged() { - drag_idx = Some(i); drag_delta = resp.drag_delta().x / track.width(); + drag_active = true; + } + if resp.drag_stopped() { + drag_ended = true; } if resp.clicked() { - click_idx = Some(i); + *selected_stop = Some(i); } + // Right-click on an interior stop (not the first or last) deletes it. + if resp.secondary_clicked() && i > 0 && i < n_stops - 1 { + delete_idx = Some(i); + } + + // Color picker popup — opens on click, closes on click-outside. + egui::containers::Popup::from_toggle_button_response(&resp) + .show(|ui| { + ui.spacing_mut().slider_width = 200.0; + let stop = &mut gradient.stops[i]; + let mut c32 = Color32::from_rgba_unmultiplied( + stop.color.r, stop.color.g, stop.color.b, stop.color.a, + ); + if egui::color_picker::color_picker_color32( + ui, &mut c32, egui::color_picker::Alpha::OnlyBlend, + ) { + // Color32 stores premultiplied RGB; unmultiply before storing + // as straight-alpha ShapeColor to avoid darkening on round-trip. + let [pr, pg, pb, a] = c32.to_array(); + let unpm = |c: u8| -> u8 { + if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 } + }; + stop.color = ShapeColor::rgba(unpm(pr), unpm(pg), unpm(pb), a); + changed = true; + } + }); } - // Apply drag. - if let (Some(i), delta) = (drag_idx, drag_delta) { - if delta != 0.0 { - let new_pos = (gradient.stops[i].position + delta).clamp(0.0, 1.0); - gradient.stops[i].position = new_pos; - // Re-sort and track the moved stop. - gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); - // Find new index of the moved stop (closest position match). - if let Some(ref mut sel) = *selected_stop { - // Re-find by position proximity. - *sel = gradient.stops.iter().enumerate() - .min_by(|(_, a), (_, b)| { - let pa = (a.position - (gradient.stops.get(i).map_or(0.0, |s| s.position))).abs(); - let pb = (b.position - (gradient.stops.get(i).map_or(0.0, |s| s.position))).abs(); - pa.partial_cmp(&pb).unwrap() - }) - .map(|(idx, _)| idx) - .unwrap_or(0); + // Apply drag to whichever stop selected_stop points at. + // Using selected_stop (anchored at drag_started) instead of the widget index + // means sorting never causes a different stop to be dragged when the dragged + // stop passes over a neighbour. + if drag_active { + if let Some(cur) = *selected_stop { + if drag_delta != 0.0 { + let new_pos = (gradient.stops[cur].position + drag_delta).clamp(0.0, 1.0); + gradient.stops[cur].position = new_pos; + gradient.stops.sort_by(|a, b| a.position.partial_cmp(&b.position).unwrap()); + // Re-find the moved stop by its new position so selected_stop stays correct. + *selected_stop = gradient.stops.iter() + .position(|s| (s.position - new_pos).abs() < 1e-5); + changed = true; } - changed = true; } } - if let Some(i) = click_idx { - *selected_stop = Some(i); + // Merge-on-drop: if the dragged stop was released within one handle-width of + // another stop, delete that other stop (provided ≥ 3 stops remain). + if drag_ended { + if let Some(cur) = *selected_stop { + if gradient.stops.len() > 2 { + let my_pos = gradient.stops[cur].position; + let merge_thresh = body_half_w / track.width(); + if let Some(victim) = gradient.stops.iter().enumerate() + .find(|&(j, s)| j != cur && (s.position - my_pos).abs() < merge_thresh) + .map(|(j, _)| j) + { + gradient.stops.remove(victim); + if victim < cur { + *selected_stop = Some(cur - 1); + } + changed = true; + } + } + } } - // Paint handles on top (after interaction so they visually react). - for (i, h_rect) in handle_rects.iter().enumerate() { + // Apply right-click delete (after loop to avoid borrow conflicts). + if let Some(i) = delete_idx { + gradient.stops.remove(i); + if *selected_stop == Some(i) { + *selected_stop = None; + } else if let Some(sel) = *selected_stop { + if sel > i { + *selected_stop = Some(sel - 1); + } + } + changed = true; + } + + // ── Paint handles ───────────────────────────────────────────────────── + // handle_rects was built before any deletions this frame; guard against OOB. + for (i, h_rect) in handle_rects.iter().enumerate().take(gradient.stops.len()) { let col = ShapeColor_to_Color32(gradient.stops[i].color); let is_selected = *selected_stop == Some(i); - - // Draw a downward-pointing triangle. - let cx = h_rect.center().x; - let top = h_rect.min.y; - let bot = h_rect.max.y; - let hw = h_rect.width() * 0.5; - let tri = vec![ - egui::pos2(cx, bot), - egui::pos2(cx - hw, top), - egui::pos2(cx + hw, top), - ]; + let stroke = Stroke::new( + if is_selected { 2.0 } else { 1.0 }, + if is_selected { Color32::WHITE } else { Color32::from_gray(80) }, + ); + let cx = h_rect.center().x; + let apex = egui::pos2(cx, track.min.y); + let shoulder_y = track.min.y + peak_h; + let bottom_y = track.min.y + handle_h; + // Convex pentagon: apex → upper-right → lower-right → lower-left → upper-left painter.add(egui::Shape::convex_polygon( - tri, + vec![ + apex, + egui::pos2(cx + body_half_w, shoulder_y), + egui::pos2(cx + body_half_w, bottom_y), + egui::pos2(cx - body_half_w, bottom_y), + egui::pos2(cx - body_half_w, shoulder_y), + ], col, - Stroke::new(if is_selected { 2.0 } else { 1.0 }, - if is_selected { Color32::WHITE } else { Color32::from_gray(100) }), + stroke, )); } - // ── Selected stop detail ────────────────────────────────────────────── + // ── Selected stop detail (position + remove) ────────────────────────── if let Some(i) = *selected_stop { if i < gradient.stops.len() { ui.separator(); ui.horizontal(|ui| { let stop = &mut gradient.stops[i]; - let mut rgba = [stop.color.r, stop.color.g, stop.color.b, stop.color.a]; - if ui.color_edit_button_srgba_unmultiplied(&mut rgba).changed() { - stop.color = ShapeColor::rgba(rgba[0], rgba[1], rgba[2], rgba[3]); - changed = true; - } ui.label("Position:"); if ui.add( DragValue::new(&mut stop.position) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 55bf6c2..1d3829d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -540,15 +540,17 @@ impl InfopanelPane { }); } - Tool::Gradient if active_is_raster => { - ui.horizontal(|ui| { - ui.label("Opacity:"); - ui.add(egui::Slider::new( - &mut shared.raster_settings.gradient_opacity, - 0.0_f32..=1.0, - ).custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.add_space(4.0); + Tool::Gradient => { + if active_is_raster { + ui.horizontal(|ui| { + ui.label("Opacity:"); + ui.add(egui::Slider::new( + &mut shared.raster_settings.gradient_opacity, + 0.0_f32..=1.0, + ).custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + }); + ui.add_space(4.0); + } gradient_stop_editor( ui, &mut shared.raster_settings.gradient, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index e862fda..4a9e487 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -1773,7 +1773,7 @@ impl egui_wgpu::CallbackTrait for VelloCallback { // Stipple faces with visible fill for (i, face) in sel_dcel.faces.iter().enumerate() { if face.deleted || i == 0 { continue; } - if face.fill_color.is_none() && face.image_fill.is_none() { continue; } + if face.fill_color.is_none() && face.image_fill.is_none() && face.gradient_fill.is_none() { continue; } let face_id = DcelFaceId(i as u32); let path = sel_dcel.face_to_bezpath_with_holes(face_id); scene.fill( @@ -2748,8 +2748,10 @@ pub struct StagePane { warp_state: Option, /// Live state for the Liquify tool. liquify_state: Option, - /// Live state for the Gradient fill tool. + /// Live state for the Gradient fill tool (raster layers). gradient_state: Option, + /// Live state for the Gradient fill tool (vector layers). + vector_gradient_state: Option, /// GPU gradient fill dispatch to run next prepare() frame. pending_gradient_op: Option, /// GPU ops for Warp/Liquify to dispatch in prepare(). @@ -2873,6 +2875,15 @@ struct GradientState { float_offset: Option<(f32, f32)>, } +/// Live state for an ongoing vector-layer Gradient fill drag. +struct VectorGradientState { + layer_id: uuid::Uuid, + time: f64, + face_ids: Vec, + start: egui::Vec2, // World-space drag start + end: egui::Vec2, // World-space drag end +} + /// GPU ops queued by the Warp/Liquify handlers for `prepare()`. enum PendingWarpOp { /// Upload control-point grid displacements and run warp-apply shader. @@ -3162,6 +3173,7 @@ impl StagePane { warp_state: None, liquify_state: None, gradient_state: None, + vector_gradient_state: None, pending_gradient_op: None, pending_warp_ops: Vec::new(), active_raster_tool: None, @@ -8747,6 +8759,11 @@ impl StagePane { None => return, }; + // Delegate to the vector handler when the active layer is a vector layer. + if let Some(AnyLayer::Vector(_)) = shared.action_executor.document().get_layer(&active_layer_id) { + return self.handle_vector_gradient_tool(ui, response, world_pos, shared, response.rect); + } + let drag_started = response.drag_started(); let dragged = response.dragged(); let drag_stopped = response.drag_stopped(); @@ -9075,7 +9092,100 @@ impl StagePane { out } - /// Compute gradient pixels and queue upload to the preview GPU canvas for next prepare(). + /// Handle the Gradient tool when the active layer is a vector layer. + /// + /// Drag start→end across a face to set its gradient angle. On release the + /// current gradient settings (stops, kind, extend) are applied via + /// `SetFillPaintAction`, which records an undo entry. + fn handle_vector_gradient_tool( + &mut self, + ui: &mut egui::Ui, + response: &egui::Response, + world_pos: egui::Vec2, + shared: &mut SharedPaneState, + rect: egui::Rect, + ) { + use lightningbeam_core::layer::AnyLayer; + use lightningbeam_core::dcel2::FaceId; + + let Some(layer_id) = *shared.active_layer_id else { return }; + + // ── Drag started: pick the face under the click origin ─────────────── + if response.drag_started() { + let click_world = ui + .input(|i| i.pointer.press_origin()) + .map(|p| { + let rel = p - rect.min - self.pan_offset; + egui::Vec2::new(rel.x / self.zoom, rel.y / self.zoom) + }) + .unwrap_or(world_pos); + + let doc = shared.action_executor.document(); + let Some(AnyLayer::Vector(vl)) = doc.get_layer(&layer_id) else { return }; + let Some(kf) = vl.keyframe_at(*shared.playback_time) else { return }; + + let point = vello::kurbo::Point::new(click_world.x as f64, click_world.y as f64); + let face_id = kf.dcel.find_face_containing_point(point); + + // Face 0 is the unbounded background face — nothing to fill. + if face_id == FaceId(0) || kf.dcel.face(face_id).deleted { return; } + + // If the clicked face is already selected, apply to all selected faces; + // otherwise apply only to the clicked face. + let face_ids: Vec = if shared.selection.selected_faces().contains(&face_id) { + shared.selection.selected_faces().iter().cloned().collect() + } else { + vec![face_id] + }; + + self.vector_gradient_state = Some(VectorGradientState { + layer_id, + time: *shared.playback_time, + face_ids, + start: click_world, + end: click_world, + }); + } + + // ── Dragged: update end point ───────────────────────────────────────── + if let Some(ref mut gs) = self.vector_gradient_state { + if response.dragged() { + gs.end = world_pos; + } + } + + // ── Drag stopped: commit gradient ───────────────────────────────────── + if response.drag_stopped() { + if let Some(gs) = self.vector_gradient_state.take() { + let dx = gs.end.x - gs.start.x; + let dy = gs.end.y - gs.start.y; + // Tiny / no drag → keep the angle stored in the current gradient settings. + let angle = if dx.abs() < 0.5 && dy.abs() < 0.5 { + shared.raster_settings.gradient.angle + } else { + dy.atan2(dx).to_degrees() + }; + + let gradient = lightningbeam_core::gradient::ShapeGradient { + kind: shared.raster_settings.gradient.kind, + stops: shared.raster_settings.gradient.stops.clone(), + angle, + extend: shared.raster_settings.gradient.extend, + start_world: Some((gs.start.x as f64, gs.start.y as f64)), + end_world: Some((gs.end.x as f64, gs.end.y as f64)), + }; + + use lightningbeam_core::actions::SetFillPaintAction; + let action = SetFillPaintAction::gradient( + gs.layer_id, gs.time, gs.face_ids, Some(gradient), + ); + if let Err(e) = shared.action_executor.execute(Box::new(action)) { + eprintln!("Vector gradient fill: {e}"); + } + } + } + } + fn handle_transform_tool( &mut self, ui: &mut egui::Ui, @@ -11705,6 +11815,25 @@ impl PaneRenderer for StagePane { ui.painter().add(cb); + // Gradient direction arrow overlay for vector gradient drags. + if matches!(*shared.selected_tool, lightningbeam_core::tool::Tool::Gradient) { + if let Some(ref gs) = self.vector_gradient_state { + let mut painter = ui.ctx().layer_painter(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("vgrad_arrow"), + )); + painter.set_clip_rect(rect); + let w2s = |w: egui::Vec2| -> egui::Pos2 { + rect.min + self.pan_offset + w * self.zoom + }; + let p0 = w2s(gs.start); + let p1 = w2s(gs.end); + painter.line_segment([p0, p1], egui::Stroke::new(2.0, egui::Color32::WHITE)); + painter.circle_stroke(p0, 5.0, egui::Stroke::new(1.5, egui::Color32::WHITE)); + painter.circle_filled(p1, 4.0, egui::Color32::WHITE); + } + } + // Show camera info overlay let info_color = shared.theme.text_color(&["#stage", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); ui.painter().text(