Add gradient support to vector graphics
This commit is contained in:
parent
8bd65e5904
commit
26f06da5bf
|
|
@ -3468,7 +3468,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "lightningbeam-editor"
|
||||
version = "1.0.1-alpha"
|
||||
version = "1.0.2-alpha"
|
||||
dependencies = [
|
||||
"beamdsp",
|
||||
"bytemuck",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -366,6 +366,7 @@ impl Tool {
|
|||
Tool::Rectangle,
|
||||
Tool::Ellipse,
|
||||
Tool::PaintBucket,
|
||||
Tool::Gradient,
|
||||
Tool::Eyedropper,
|
||||
Tool::Line,
|
||||
Tool::Polygon,
|
||||
|
|
|
|||
|
|
@ -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<usize> = None;
|
||||
let mut drag_delta: f32 = 0.0;
|
||||
let mut click_idx: Option<usize> = None;
|
||||
|
||||
// To render handles after collecting, remember their rects.
|
||||
// Top-anchored hit rects (peak touches track.min.y).
|
||||
let handle_rects: Vec<Rect> = (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<usize> = 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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<WarpState>,
|
||||
/// Live state for the Liquify tool.
|
||||
liquify_state: Option<LiquifyState>,
|
||||
/// Live state for the Gradient fill tool.
|
||||
/// Live state for the Gradient fill tool (raster layers).
|
||||
gradient_state: Option<GradientState>,
|
||||
/// Live state for the Gradient fill tool (vector layers).
|
||||
vector_gradient_state: Option<VectorGradientState>,
|
||||
/// GPU gradient fill dispatch to run next prepare() frame.
|
||||
pending_gradient_op: Option<PendingGradientOp>,
|
||||
/// 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<lightningbeam_core::dcel2::FaceId>,
|
||||
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<FaceId> = 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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue