mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 21:18:11 -06:00
785 lines
35 KiB
Rust
785 lines
35 KiB
Rust
//! The Detail (loupe) view: the developed photo, before/after, zoom & pan, the filmstrip, and the
|
|
//! on-canvas tools (crop, brush/gradient masks, remove spots, white-balance picker).
|
|
|
|
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
|
use lightcraft_catalog::PhotoId;
|
|
use lightcraft_develop::{DevelopSettings, MaskShape};
|
|
use lightcraft_geom::{Affine, Point};
|
|
use lightcraft_pipeline::geometry::Frame;
|
|
use serde_json::json;
|
|
|
|
use crate::LightcraftApp;
|
|
use crate::render::Slot;
|
|
use crate::state::{BeforeAfter, CropOverlay, RightPanel, Zoom};
|
|
use crate::theme::Tokens;
|
|
use crate::widgets::register;
|
|
|
|
/// An in-progress on-canvas gesture.
|
|
#[derive(Clone, Debug)]
|
|
pub enum Gesture {
|
|
Brush {
|
|
points: Vec<Point>,
|
|
},
|
|
Spot {
|
|
points: Vec<Point>,
|
|
},
|
|
CropHandle {
|
|
handle: u8,
|
|
start: lightcraft_geom::Rect,
|
|
angle: f64,
|
|
},
|
|
CropRotate {
|
|
start_angle: f64,
|
|
a0: f32,
|
|
},
|
|
Pan {
|
|
start: (f32, f32),
|
|
at: Pos2,
|
|
},
|
|
MaskHandle {
|
|
mask: u32,
|
|
handle: u8,
|
|
},
|
|
/// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates).
|
|
Guide {
|
|
a: Point,
|
|
},
|
|
}
|
|
|
|
/// Screen ↔ normalized-image mapping for the displayed frame.
|
|
#[derive(Clone, Copy)]
|
|
pub struct CanvasMap {
|
|
rect: Rect,
|
|
to_norm: Affine,
|
|
from_norm: Affine,
|
|
k: f32,
|
|
}
|
|
|
|
impl CanvasMap {
|
|
fn new(frame: &Frame, rect: Rect) -> CanvasMap {
|
|
let k = 8.0f32;
|
|
let (w, h) = ((rect.width() * k).round().max(1.0) as usize, (rect.height() * k).round().max(1.0) as usize);
|
|
let to_norm = frame.out_to_norm(w, h);
|
|
CanvasMap { rect, to_norm, from_norm: to_norm.inverse().unwrap_or(Affine::IDENTITY), k }
|
|
}
|
|
pub fn norm(&self, p: Pos2) -> Point {
|
|
self.to_norm.apply(Point::new(((p.x - self.rect.left()) * self.k) as f64, ((p.y - self.rect.top()) * self.k) as f64))
|
|
}
|
|
pub fn screen(&self, n: Point) -> Pos2 {
|
|
let q = self.from_norm.apply(n);
|
|
pos2(self.rect.left() + q.x as f32 / self.k, self.rect.top() + q.y as f32 / self.k)
|
|
}
|
|
}
|
|
|
|
fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], ppp: f32, pan: (f32, f32)) -> Rect {
|
|
let (aw, ah) = (area.width(), area.height());
|
|
let (w, h) = match zoom {
|
|
Zoom::Fit => {
|
|
if aw / ah > aspect {
|
|
(ah * aspect, ah)
|
|
} else {
|
|
(aw, aw / aspect)
|
|
}
|
|
}
|
|
Zoom::Fill => {
|
|
if aw / ah > aspect {
|
|
(aw, aw / aspect)
|
|
} else {
|
|
(ah * aspect, ah)
|
|
}
|
|
}
|
|
Zoom::Percent(p) => {
|
|
// full-resolution pixels at p% (the photo's native width)
|
|
let w = img_px[0] as f32 * p as f32 / 100.0 / ppp;
|
|
(w, w / aspect)
|
|
}
|
|
};
|
|
let c = if w <= aw && h <= ah {
|
|
area.center()
|
|
} else {
|
|
// pan: which normalized point of the image sits at the area centre
|
|
pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h)
|
|
};
|
|
Rect::from_center_size(c, vec2(w, h))
|
|
}
|
|
|
|
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|
let t = Tokens::get(ui.ctx());
|
|
let full = ui.max_rect();
|
|
let film_h = if app.ui.filmstrip { t.film_h } else { 0.0 };
|
|
let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h));
|
|
app.canvas_rect = Some(canvas);
|
|
if app.ui.filmstrip {
|
|
filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max));
|
|
}
|
|
let Some(id) = app.session.active() else {
|
|
super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip");
|
|
return;
|
|
};
|
|
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
|
|
let d = (*photo.develop).clone();
|
|
let crop_tool = app.ui.right == RightPanel::Crop;
|
|
let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref());
|
|
let aspect = frame.aspect() as f32;
|
|
let ppp = ui.ctx().pixels_per_point();
|
|
let area = canvas.shrink(if crop_tool { 48.0 } else { 24.0 });
|
|
let native = [photo.width.max(1) as usize, photo.height.max(1) as usize];
|
|
// two views (before, after): side by side or stacked
|
|
let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom);
|
|
let split_view = matches!(app.ui.before_after, BeforeAfter::Split | BeforeAfter::SplitTopBottom);
|
|
let areas: Vec<Rect> = match app.ui.before_after {
|
|
BeforeAfter::SideBySide => {
|
|
let half = area.width() / 2.0 - 6.0;
|
|
vec![
|
|
Rect::from_min_size(area.min, vec2(half, area.height())),
|
|
Rect::from_min_size(pos2(area.center().x + 6.0, area.top()), vec2(half, area.height())),
|
|
]
|
|
}
|
|
BeforeAfter::TopBottom => {
|
|
let half = area.height() / 2.0 - 14.0;
|
|
vec![
|
|
Rect::from_min_size(area.min, vec2(area.width(), half)),
|
|
Rect::from_min_size(pos2(area.left(), area.center().y + 14.0), vec2(area.width(), half)),
|
|
]
|
|
}
|
|
_ => vec![area],
|
|
};
|
|
let main_area = *areas.last().unwrap_or(&area);
|
|
let img_rect = fit_rect(main_area, aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
|
app.image_rect = Some(img_rect);
|
|
// request renders: the loupe at display resolution (drafts during drags)
|
|
let interacting = app.session.interaction.is_some();
|
|
let scale = if interacting { 0.6 } else { 1.0 };
|
|
let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(2560.0) as usize;
|
|
let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) };
|
|
if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) {
|
|
let job = if interacting { job.draft() } else { job };
|
|
app.renderer.request(Slot::Main, job, 100);
|
|
}
|
|
// once this photo is on screen: prepare its neighbours in filmstrip order (source decoded and
|
|
// kept, view render cached) so stepping to them is instant
|
|
if !interacting && !app.renderer.is_pending(Slot::Main) && app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.photo == id) {
|
|
let ids = app.session.visible_cloned();
|
|
if let Some(i) = ids.iter().position(|p| *p == id) {
|
|
let next = ids.get(i + 1).copied();
|
|
let prev = i.checked_sub(1).and_then(|j| ids.get(j)).copied();
|
|
for (n, nid) in [next, prev].into_iter().enumerate() {
|
|
let Some(nid) = nid else { continue };
|
|
let Some(np) = app.session.catalog.photo(nid).cloned() else { continue };
|
|
let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref());
|
|
let na = nf.aspect() as f32;
|
|
let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan);
|
|
let nw = (nr.width().max(nr.height()) * ppp).min(2560.0) as usize;
|
|
let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) };
|
|
if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) {
|
|
app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// until the loupe has this photo: show its cached render / embedded preview / a thumbnail
|
|
if app.renderer.textures.get(&Slot::Main).is_none_or(|t| t.photo != id)
|
|
&& let Some(q) = app.session.quick_view_job(id, want.max(8), !crop_tool)
|
|
{
|
|
app.renderer.request_quick(Slot::Preview, q, 110);
|
|
}
|
|
let show_before = app.ui.before_after == BeforeAfter::Original || ui.input(|i| i.key_down(egui::Key::Backslash));
|
|
if (split || show_before || split_view)
|
|
&& let Some(job) = app.session.render_job(id, rw.max(8), rh.max(8), true, !crop_tool)
|
|
{
|
|
app.renderer.request(Slot::Before, job, 90);
|
|
}
|
|
let p = ui.painter_at(canvas);
|
|
// what a view slot shows: its own render of this photo, else the stand-ins (no blank frame
|
|
// between photos, and the full render replaces them in place)
|
|
let draw = |slot: Slot, r: Rect| -> &'static str {
|
|
let mine = |s: Slot| app.renderer.textures.get(&s).filter(|t| t.photo == id);
|
|
let (tex, what) = if let Some(t) = mine(slot) {
|
|
(t, "render")
|
|
} else if let Some(t) = mine(Slot::Preview) {
|
|
(t, t.quick.map(quick_name).unwrap_or("preview"))
|
|
} else if let Some(t) = app.renderer.thumb(id) {
|
|
(t, "thumb")
|
|
} else {
|
|
return "none";
|
|
};
|
|
p.image(tex.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
|
what
|
|
};
|
|
let shown;
|
|
if split {
|
|
let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan);
|
|
draw(Slot::Before, br);
|
|
shown = draw(Slot::Main, img_rect);
|
|
p.text(pos2(br.left(), br.bottom() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text_dim);
|
|
p.text(pos2(img_rect.left(), img_rect.bottom() + 14.0), Align2::LEFT_CENTER, "After", t.font(12.0), t.text_dim);
|
|
} else if show_before {
|
|
shown = draw(Slot::Before, img_rect);
|
|
p.text(pos2(img_rect.left() + 8.0, img_rect.top() + 14.0), Align2::LEFT_CENTER, "Before", t.font(12.0), t.text);
|
|
} else {
|
|
shown = draw(Slot::Main, img_rect);
|
|
if shown == "none" {
|
|
p.text(canvas.center(), Align2::CENTER_CENTER, "Rendering…", t.font(13.0), t.text_dim);
|
|
}
|
|
}
|
|
app.loupe_shown = Some((id, shown));
|
|
if app.ui.before_after == BeforeAfter::Split {
|
|
let mid = img_rect.center().x;
|
|
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
|
|
let left = Rect::from_min_max(img_rect.min, pos2(mid, img_rect.bottom()));
|
|
p.image(tex.tex.id(), left, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0)), Color32::WHITE);
|
|
}
|
|
p.line_segment([pos2(mid, img_rect.top()), pos2(mid, img_rect.bottom())], Stroke::new(1.5, Color32::WHITE));
|
|
}
|
|
if app.ui.before_after == BeforeAfter::SplitTopBottom {
|
|
let mid = img_rect.center().y;
|
|
if let Some(tex) = app.renderer.textures.get(&Slot::Before).filter(|t| t.photo == id) {
|
|
let top = Rect::from_min_max(img_rect.min, pos2(img_rect.right(), mid));
|
|
p.image(tex.tex.id(), top, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 0.5)), Color32::WHITE);
|
|
}
|
|
p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE));
|
|
}
|
|
if app.ui.show_clipping && !show_before {
|
|
clipping_overlay(app, &p, img_rect);
|
|
}
|
|
register(ui.ctx(), "canvas:image", img_rect);
|
|
let map = CanvasMap::new(&frame, img_rect);
|
|
let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag());
|
|
match app.ui.right {
|
|
RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id),
|
|
RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d),
|
|
RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d),
|
|
_ => general_interaction(app, ui, &resp, &map, img_rect, canvas, native, aspect),
|
|
}
|
|
resp.context_menu(|ui| super::grid::context_menu(app, ui, id));
|
|
}
|
|
|
|
/// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes.
|
|
const PREFETCH_PRIORITY: u32 = 4;
|
|
|
|
fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str {
|
|
use lightcraft_engine::media::QuickSource;
|
|
match q {
|
|
QuickSource::Cached => "cached",
|
|
QuickSource::Embedded => "embedded",
|
|
QuickSource::Small => "small",
|
|
}
|
|
}
|
|
|
|
fn clipping_overlay(app: &LightcraftApp, p: &egui::Painter, r: Rect) {
|
|
// Highlight clipped regions using the histogram's extremes isn't spatial; show a subtle frame hint.
|
|
if let Some(h) = app.renderer.textures.get(&Slot::Main).and_then(|t| t.histogram.as_ref()) {
|
|
let (lo, hi) = h.clipping();
|
|
if hi > 0.003 {
|
|
p.rect_stroke(r, 0.0, Stroke::new(2.0, Color32::from_rgb(255, 60, 60)), StrokeKind::Outside);
|
|
}
|
|
if lo > 0.003 {
|
|
p.rect_stroke(r.expand(3.0), 0.0, Stroke::new(2.0, Color32::from_rgb(60, 120, 255)), StrokeKind::Outside);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn general_interaction(
|
|
app: &mut LightcraftApp,
|
|
ui: &mut egui::Ui,
|
|
resp: &egui::Response,
|
|
map: &CanvasMap,
|
|
img: Rect,
|
|
canvas: Rect,
|
|
native: [usize; 2],
|
|
aspect: f32,
|
|
) {
|
|
if app.ui.tool == "wbPicker" {
|
|
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
|
if resp.clicked()
|
|
&& let Some(q) = resp.interact_pointer_pos()
|
|
{
|
|
let n = map.norm(q);
|
|
let _ = app.run("develop.wbPick", json!({"x": n.x, "y": n.y}));
|
|
app.ui.tool.clear();
|
|
}
|
|
return;
|
|
}
|
|
// click toggles Fit ↔ 100 % at the clicked point; drag pans when zoomed
|
|
let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0;
|
|
if resp.double_clicked() || (resp.clicked() && !zoomed) {
|
|
if let Some(q) = resp.interact_pointer_pos() {
|
|
let u = ((q.x - img.left()) / img.width()).clamp(0.0, 1.0);
|
|
let v = ((q.y - img.top()) / img.height()).clamp(0.0, 1.0);
|
|
app.ui.pan = (u, v);
|
|
}
|
|
app.ui.zoom = if matches!(app.ui.zoom, Zoom::Fit) { Zoom::Percent(100) } else { Zoom::Fit };
|
|
} else if resp.clicked() && zoomed {
|
|
app.ui.zoom = Zoom::Fit;
|
|
}
|
|
if zoomed {
|
|
ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
|
if resp.dragged() {
|
|
let dlt = resp.drag_delta();
|
|
app.ui.pan.0 = (app.ui.pan.0 - dlt.x / img.width()).clamp(0.0, 1.0);
|
|
app.ui.pan.1 = (app.ui.pan.1 - dlt.y / img.height()).clamp(0.0, 1.0);
|
|
}
|
|
}
|
|
let _ = (native, aspect);
|
|
}
|
|
|
|
// ------------------------------------------------------------------------ crop
|
|
|
|
fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings, id: PhotoId) {
|
|
if app.ui.tool == "guidedUpright" {
|
|
guided_overlay(app, ui, resp, map, frame, d);
|
|
return;
|
|
}
|
|
let quad = frame_crop_quad(d, frame);
|
|
let pts: Vec<Pos2> = quad.iter().map(|q| map.screen(*q)).collect();
|
|
let p = ui.painter();
|
|
// darken outside the crop
|
|
let img = map.rect;
|
|
let shade = Color32::from_black_alpha(150);
|
|
let mut mesh = egui::epaint::Mesh::default();
|
|
let outer = [img.left_top(), img.right_top(), img.right_bottom(), img.left_bottom()];
|
|
for i in 0..4 {
|
|
let a = outer[i];
|
|
let b = outer[(i + 1) % 4];
|
|
let c = pts[(i + 1) % 4];
|
|
let dd = pts[i];
|
|
let base = mesh.vertices.len() as u32;
|
|
for v in [a, b, c, dd] {
|
|
mesh.vertices.push(egui::epaint::Vertex { pos: v, uv: Pos2::ZERO, color: shade });
|
|
}
|
|
mesh.indices.extend([base, base + 1, base + 2, base, base + 2, base + 3]);
|
|
}
|
|
p.add(mesh);
|
|
p.add(egui::Shape::closed_line(pts.clone(), Stroke::new(1.0, Color32::WHITE)));
|
|
// overlay guides
|
|
let lerp = |a: Pos2, b: Pos2, t: f32| a + (b - a) * t;
|
|
let guide = Stroke::new(0.8, Color32::from_white_alpha(150));
|
|
let fracs: Vec<f32> = match app.ui.crop_overlay {
|
|
CropOverlay::Thirds => vec![1.0 / 3.0, 2.0 / 3.0],
|
|
CropOverlay::Grid => (1..8).map(|i| i as f32 / 8.0).collect(),
|
|
CropOverlay::Golden => vec![0.382, 0.618],
|
|
CropOverlay::Diagonal | CropOverlay::None => vec![],
|
|
};
|
|
for f in fracs {
|
|
p.line_segment([lerp(pts[0], pts[1], f), lerp(pts[3], pts[2], f)], guide);
|
|
p.line_segment([lerp(pts[0], pts[3], f), lerp(pts[1], pts[2], f)], guide);
|
|
}
|
|
// handles: 0..3 corners, 4..7 edges (top, right, bottom, left)
|
|
let handles: Vec<Pos2> = (0..8).map(|i| if i < 4 { pts[i] } else { lerp(pts[i - 4], pts[(i - 3) % 4], 0.5) }).collect();
|
|
for (i, h) in handles.iter().enumerate() {
|
|
register(ui.ctx(), format!("cropHandle:{i}"), Rect::from_center_size(*h, vec2(14.0, 14.0)));
|
|
let s = if i < 4 { 12.0 } else { 9.0 };
|
|
p.rect_filled(Rect::from_center_size(*h, vec2(s, 3.0)), 0.0, Color32::WHITE);
|
|
p.rect_filled(Rect::from_center_size(*h, vec2(3.0, s)), 0.0, Color32::WHITE);
|
|
}
|
|
let inside = |q: Pos2| {
|
|
let n = map.norm(q);
|
|
let s = to_straight(n, d.crop.geometry.angle, frame);
|
|
d.crop.geometry.rect.contains(Point::new(s.x.clamp(-1.0, 2.0), s.y))
|
|
};
|
|
if let Some(hq) = resp.hover_pos() {
|
|
let near = handles.iter().position(|h| h.distance(hq) < 12.0);
|
|
ui.ctx().set_cursor_icon(match near {
|
|
Some(0 | 2) => egui::CursorIcon::ResizeNwSe,
|
|
Some(1 | 3) => egui::CursorIcon::ResizeNeSw,
|
|
Some(4 | 6) => egui::CursorIcon::ResizeVertical,
|
|
Some(_) => egui::CursorIcon::ResizeHorizontal,
|
|
None if inside(hq) => egui::CursorIcon::Move,
|
|
None => egui::CursorIcon::Alias,
|
|
});
|
|
}
|
|
if resp.drag_started()
|
|
&& let Some(q) = resp.interact_pointer_pos()
|
|
{
|
|
let _ = app.run("develop.beginInteraction", json!({"label": "Crop"}));
|
|
app.gesture = Some(match handles.iter().position(|h| h.distance(q) < 12.0) {
|
|
Some(h) => Gesture::CropHandle { handle: h as u8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle },
|
|
None if inside(q) => Gesture::CropHandle { handle: 8, start: d.crop.geometry.rect, angle: d.crop.geometry.angle },
|
|
None => {
|
|
let c = map.screen(Point::new(0.5, 0.5));
|
|
Gesture::CropRotate { start_angle: d.crop.geometry.angle, a0: (q - c).angle() }
|
|
}
|
|
});
|
|
}
|
|
if resp.dragged()
|
|
&& let Some(q) = resp.interact_pointer_pos()
|
|
{
|
|
match app.gesture.clone() {
|
|
Some(Gesture::CropHandle { handle, start, angle }) => {
|
|
let n = to_straight(map.norm(q), angle, frame);
|
|
let o = resp.interact_pointer_pos().map(|q0| q0 - resp.drag_delta()).unwrap_or(q);
|
|
let _ = o;
|
|
let mut r = start;
|
|
let orig = ui.input(|i| i.pointer.press_origin()).map(|q0| to_straight(map.norm(q0), angle, frame)).unwrap_or(n);
|
|
let (dx, dy) = (n.x - orig.x, n.y - orig.y);
|
|
match handle {
|
|
0 => (r.x0, r.y0) = (start.x0 + dx, start.y0 + dy),
|
|
1 => (r.x1, r.y0) = (start.x1 + dx, start.y0 + dy),
|
|
2 => (r.x1, r.y1) = (start.x1 + dx, start.y1 + dy),
|
|
3 => (r.x0, r.y1) = (start.x0 + dx, start.y1 + dy),
|
|
4 => r.y0 = start.y0 + dy,
|
|
5 => r.x1 = start.x1 + dx,
|
|
6 => r.y1 = start.y1 + dy,
|
|
7 => r.x0 = start.x0 + dx,
|
|
_ => r = start.translate(lightcraft_geom::Vec2::new(dx, dy)),
|
|
}
|
|
// aspect lock
|
|
if let Some((aw, ah)) = d.crop.aspect
|
|
&& handle < 4
|
|
{
|
|
let (iw, ih) = (frame.ow, frame.oh);
|
|
let a = if (iw >= ih) == (aw >= ah) { aw as f64 / ah as f64 } else { ah as f64 / aw as f64 };
|
|
let w_px = (r.x1 - r.x0).abs() * iw;
|
|
let h_px = w_px / a;
|
|
let hn = h_px / ih;
|
|
if handle == 0 || handle == 1 {
|
|
r.y0 = r.y1 - hn;
|
|
} else {
|
|
r.y1 = r.y0 + hn;
|
|
}
|
|
}
|
|
let rr = lightcraft_geom::Rect::new(r.x0.min(r.x1), r.y0.min(r.y1), r.x0.max(r.x1), r.y0.max(r.y1));
|
|
if rr.width() > 0.02 && rr.height() > 0.02 {
|
|
let _ = app.run("crop.set", json!({"rect": [rr.x0, rr.y0, rr.x1, rr.y1]}));
|
|
}
|
|
}
|
|
Some(Gesture::CropRotate { start_angle, a0 }) => {
|
|
let c = map.screen(Point::new(0.5, 0.5));
|
|
let a = (q - c).angle();
|
|
let ang = (start_angle + (a - a0).to_degrees() as f64).clamp(-45.0, 45.0);
|
|
let _ = app.run("crop.straighten", json!({"angle": (ang * 100.0).round() / 100.0}));
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
if resp.drag_stopped() {
|
|
app.gesture = None;
|
|
let _ = app.run("develop.endInteraction", json!({}));
|
|
}
|
|
let _ = id;
|
|
}
|
|
|
|
/// Guided Upright: draw up to four guides along lines that should be vertical or horizontal. Guides are
|
|
/// stored in lens-corrected (pre-perspective) coordinates, so they stay attached to the image as it warps.
|
|
fn guided_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, frame: &Frame, d: &DevelopSettings) {
|
|
let p = ui.painter_at(map.rect.expand(8.0));
|
|
let col = Color32::from_rgb(255, 196, 40);
|
|
let draw = |a: Pos2, b: Pos2| {
|
|
p.line_segment([a, b], Stroke::new(3.0, Color32::from_black_alpha(120)));
|
|
p.line_segment([a, b], Stroke::new(1.5, col));
|
|
for q in [a, b] {
|
|
p.circle_filled(q, 4.0, col);
|
|
p.circle_stroke(q, 4.0, Stroke::new(1.0, Color32::BLACK));
|
|
}
|
|
};
|
|
for (i, (a, b)) in d.geometry.guides.iter().enumerate() {
|
|
let (sa, sb) = (map.screen(frame.corrected_to_transformed(*a)), map.screen(frame.corrected_to_transformed(*b)));
|
|
register(ui.ctx(), format!("uprightGuide:{i}"), Rect::from_two_pos(sa, sb));
|
|
draw(sa, sb);
|
|
}
|
|
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
|
if resp.drag_started()
|
|
&& let Some(q) = resp.interact_pointer_pos()
|
|
{
|
|
app.gesture = Some(Gesture::Guide { a: map.norm(q) });
|
|
}
|
|
if let (Some(Gesture::Guide { a }), Some(q)) = (&app.gesture, resp.interact_pointer_pos()) {
|
|
draw(map.screen(*a), q);
|
|
}
|
|
if resp.drag_stopped()
|
|
&& let (Some(Gesture::Guide { a }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
|
|
{
|
|
app.gesture = None;
|
|
let b = map.norm(q);
|
|
if a.dist(b) > 0.02 {
|
|
let (ca, cb) = (frame.transformed_to_corrected(a), frame.transformed_to_corrected(b));
|
|
let _ = app.run("geometry.guides", json!({"guides": [[ca.x, ca.y, cb.x, cb.y]], "add": true}));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Normalized oriented coords → the straightened (rotated) frame the crop rect lives in.
|
|
fn to_straight(n: Point, angle: f64, frame: &Frame) -> Point {
|
|
let (w, h) = (frame.ow, frame.oh);
|
|
let px = Point::new(n.x * w, n.y * h);
|
|
let r = Affine::rotate_about(angle.to_radians(), Point::new(w / 2.0, h / 2.0)).apply(px);
|
|
Point::new(r.x / w, r.y / h)
|
|
}
|
|
|
|
fn frame_crop_quad(d: &DevelopSettings, frame: &Frame) -> [Point; 4] {
|
|
let f = Frame { crop: d.crop.geometry, ..frame.clone() };
|
|
f.crop_quad_norm()
|
|
}
|
|
|
|
// ------------------------------------------------------------------------ masks
|
|
|
|
fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
|
|
let red = Color32::from_rgba_unmultiplied(230, 30, 40, 90);
|
|
let long = (map.rect.width().max(map.rect.height())) as f64;
|
|
let active = app.session.active_mask;
|
|
let clip = app.canvas_rect.unwrap_or(map.rect);
|
|
let p = &ui.painter_at(clip);
|
|
for m in &d.masks {
|
|
let sel = Some(m.id) == active;
|
|
for c in &m.components {
|
|
match &c.shape {
|
|
MaskShape::Brush { strokes } if sel && app.ui.mask_overlay => {
|
|
for s in strokes {
|
|
let r = (s.size * long) as f32;
|
|
for q in &s.points {
|
|
p.circle_filled(map.screen(*q), r, if s.erase { Color32::from_black_alpha(60) } else { red });
|
|
}
|
|
}
|
|
}
|
|
MaskShape::Radial { center, rx, ry, angle, .. } => {
|
|
let c0 = map.screen(*center);
|
|
let pts: Vec<Pos2> = (0..64)
|
|
.map(|i| {
|
|
let a = i as f64 / 64.0 * std::f64::consts::TAU;
|
|
let (s, co) = angle.to_radians().sin_cos();
|
|
let (x, y) = (rx * a.cos(), ry * a.sin());
|
|
let l = frame_long_norm(map);
|
|
map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1))
|
|
})
|
|
.collect();
|
|
if sel && app.ui.mask_overlay {
|
|
p.add(egui::Shape::convex_polygon(pts.clone(), red, Stroke::NONE));
|
|
}
|
|
p.add(egui::Shape::closed_line(
|
|
pts,
|
|
Stroke::new(if sel { 1.5 } else { 1.0 }, Color32::from_white_alpha(if sel { 230 } else { 120 })),
|
|
));
|
|
pin(p, c0, sel);
|
|
if sel {
|
|
register(ui.ctx(), format!("maskPin:{}", m.id), Rect::from_center_size(c0, vec2(14.0, 14.0)));
|
|
}
|
|
}
|
|
MaskShape::Linear { start, end } => {
|
|
let (a, b) = (map.screen(*start), map.screen(*end));
|
|
let dir = (b - a).normalized();
|
|
let perp = vec2(-dir.y, dir.x) * 3000.0;
|
|
for (q, alpha) in [(a, 230), (b, 110)] {
|
|
p.line_segment([q - perp, q + perp], Stroke::new(1.0, Color32::from_white_alpha(if sel { alpha } else { 80 })));
|
|
}
|
|
pin(p, a + (b - a) * 0.5, sel);
|
|
if sel {
|
|
for (i, q) in [a, b].iter().enumerate() {
|
|
p.circle_stroke(*q, 5.0, Stroke::new(1.5, Color32::WHITE));
|
|
register(ui.ctx(), format!("maskHandle:{}:{i}", m.id), Rect::from_center_size(*q, vec2(14.0, 14.0)));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
// brush tool
|
|
if app.ui.tool == "brush" {
|
|
let r = (app.ui.brush_size as f64 * long) as f32;
|
|
if let Some(h) = resp.hover_pos() {
|
|
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
|
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
|
|
p.circle_stroke(h, r * (1.0 - app.ui.brush_feather / 100.0).max(0.05), Stroke::new(1.0, Color32::from_white_alpha(120)));
|
|
}
|
|
if let Some(q) = resp.interact_pointer_pos()
|
|
&& (resp.drag_started() || resp.dragged() || resp.clicked())
|
|
{
|
|
let n = map.norm(q);
|
|
match &mut app.gesture {
|
|
Some(Gesture::Brush { points }) => points.push(n),
|
|
_ => app.gesture = Some(Gesture::Brush { points: vec![n] }),
|
|
}
|
|
}
|
|
if let Some(Gesture::Brush { points }) = &app.gesture {
|
|
for q in points {
|
|
p.circle_filled(map.screen(*q), r, red);
|
|
}
|
|
}
|
|
if (resp.drag_stopped() || resp.clicked())
|
|
&& let Some(Gesture::Brush { points }) = app.gesture.take()
|
|
{
|
|
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
|
let _ = app.run(
|
|
"mask.brushStroke",
|
|
json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "erase": app.ui.brush_erase}),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
// drag pins/handles of the active mask
|
|
let Some(mid) = active else { return };
|
|
let Some(m) = d.masks.iter().find(|m| m.id == mid) else { return };
|
|
let Some(comp) = m.components.first() else { return };
|
|
if resp.drag_started()
|
|
&& let Some(q) = resp.interact_pointer_pos()
|
|
{
|
|
let handle = match &comp.shape {
|
|
MaskShape::Radial { center, .. } => (map.screen(*center).distance(q) < 14.0).then_some(0),
|
|
MaskShape::Linear { start, end } => {
|
|
if map.screen(*start).distance(q) < 14.0 {
|
|
Some(1)
|
|
} else if map.screen(*end).distance(q) < 14.0 {
|
|
Some(2)
|
|
} else {
|
|
Some(0)
|
|
}
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some(h) = handle {
|
|
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"}));
|
|
app.gesture = Some(Gesture::MaskHandle { mask: mid, handle: h });
|
|
}
|
|
}
|
|
if resp.dragged()
|
|
&& let (Some(Gesture::MaskHandle { mask, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
|
|
{
|
|
let n = map.norm(q);
|
|
let dn = {
|
|
let q0 = q - resp.drag_delta();
|
|
let n0 = map.norm(q0);
|
|
Point::new(n.x - n0.x, n.y - n0.y)
|
|
};
|
|
let new_shape = match comp.shape.clone() {
|
|
MaskShape::Radial { center, rx, ry, angle, feather, invert } => {
|
|
MaskShape::Radial { center: Point::new(center.x + dn.x, center.y + dn.y), rx, ry, angle, feather, invert }
|
|
}
|
|
MaskShape::Linear { start, end } => match handle {
|
|
1 => MaskShape::Linear { start: n, end },
|
|
2 => MaskShape::Linear { start, end: n },
|
|
_ => MaskShape::Linear { start: Point::new(start.x + dn.x, start.y + dn.y), end: Point::new(end.x + dn.x, end.y + dn.y) },
|
|
},
|
|
s => s,
|
|
};
|
|
let _ = app.run("mask.update", json!({"id": mask, "shape": new_shape}));
|
|
}
|
|
if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) {
|
|
app.gesture = None;
|
|
let _ = app.run("develop.endInteraction", json!({}));
|
|
}
|
|
}
|
|
|
|
/// Long-edge units → normalized units for x and y.
|
|
fn frame_long_norm(map: &CanvasMap) -> (f64, f64) {
|
|
// derive from the mapping: 1 normalized unit in x and y in screen points
|
|
let o = map.screen(Point::new(0.0, 0.0));
|
|
let x = map.screen(Point::new(1.0, 0.0)).distance(o) as f64;
|
|
let y = map.screen(Point::new(0.0, 1.0)).distance(o) as f64;
|
|
let l = x.max(y);
|
|
(l / x.max(1e-9), l / y.max(1e-9))
|
|
}
|
|
|
|
fn pin(p: &egui::Painter, c: Pos2, sel: bool) {
|
|
p.circle_filled(c, 6.0, if sel { Color32::from_rgb(1, 101, 221) } else { Color32::from_gray(200) });
|
|
p.circle_stroke(c, 6.0, Stroke::new(1.5, Color32::WHITE));
|
|
}
|
|
|
|
// ------------------------------------------------------------------------ remove
|
|
|
|
fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) {
|
|
let long = (map.rect.width().max(map.rect.height())) as f64;
|
|
let p = ui.painter();
|
|
for s in &d.spots {
|
|
let r = (s.size * long) as f32;
|
|
for q in &s.points {
|
|
p.circle_stroke(map.screen(*q), r, Stroke::new(1.0, Color32::from_white_alpha(200)));
|
|
}
|
|
}
|
|
let r = (app.ui.remove_size as f64 * long) as f32;
|
|
if let Some(h) = resp.hover_pos() {
|
|
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
|
p.circle_stroke(h, r, Stroke::new(1.0, Color32::WHITE));
|
|
}
|
|
if let Some(q) = resp.interact_pointer_pos()
|
|
&& (resp.drag_started() || resp.dragged() || resp.clicked())
|
|
{
|
|
let n = map.norm(q);
|
|
match &mut app.gesture {
|
|
Some(Gesture::Spot { points }) => points.push(n),
|
|
_ => app.gesture = Some(Gesture::Spot { points: vec![n] }),
|
|
}
|
|
}
|
|
if let Some(Gesture::Spot { points }) = &app.gesture {
|
|
for q in points {
|
|
p.circle_filled(map.screen(*q), r, Color32::from_white_alpha(60));
|
|
}
|
|
}
|
|
if (resp.drag_stopped() || resp.clicked())
|
|
&& let Some(Gesture::Spot { points }) = app.gesture.take()
|
|
{
|
|
let mode = match app.ui.tool.as_str() {
|
|
"heal" => "heal",
|
|
"clone" => "clone",
|
|
_ => "remove",
|
|
};
|
|
let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect();
|
|
let _ = app.run("spot.add", json!({"mode": mode, "points": pts, "size": app.ui.remove_size}));
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------------ filmstrip
|
|
|
|
fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
|
let t = Tokens::get(ui.ctx());
|
|
ui.painter().rect_filled(r, 0.0, t.canvas);
|
|
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 4.0)), 0.0, Color32::from_gray(0x20));
|
|
let ids = app.session.visible_cloned();
|
|
let active = app.session.selection.active;
|
|
let cell_w = 120.0;
|
|
let ppp = ui.ctx().pixels_per_point();
|
|
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(0.0, 4.0))));
|
|
let scroll_key = egui::Id::new("film-last-active");
|
|
let last: Option<PhotoId> = child.data(|d| d.get_temp(scroll_key));
|
|
child.data_mut(|d| d.insert_temp(scroll_key, active));
|
|
egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| {
|
|
let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover());
|
|
for (i, id) in ids.iter().enumerate() {
|
|
let cr = Rect::from_min_size(pos2(area.left() + i as f32 * cell_w, area.top()), vec2(cell_w, area.height()));
|
|
if Some(*id) == active && last != active {
|
|
ui.scroll_to_rect(cr, Some(egui::Align::Center));
|
|
}
|
|
let local = Rect::from_min_size(pos2(i as f32 * cell_w, 0.0), cr.size());
|
|
if !local.intersects(vp.expand2(vec2(cell_w * 4.0, 0.0))) {
|
|
continue;
|
|
}
|
|
let resp = ui.interact(cr, egui::Id::new(("film", id.0)), Sense::click());
|
|
register(ui.ctx(), format!("film:{}", id.0), cr);
|
|
let sel = Some(*id) == active;
|
|
let p = ui.painter();
|
|
if sel {
|
|
p.rect_filled(cr, 0.0, t.cell_selected);
|
|
} else if resp.hovered() {
|
|
p.rect_filled(cr, 0.0, t.cell_selected.gamma_multiply(0.6));
|
|
}
|
|
if let Some(ph) = app.session.catalog.photo(*id) {
|
|
let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name);
|
|
let short: String = if name.len() > 14 { format!("{}…", &name[..13]) } else { name.to_string() };
|
|
p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, short, t.font(10.0), t.text_dim);
|
|
p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim);
|
|
}
|
|
let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));
|
|
super::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8);
|
|
if let Some(tex) = app.renderer.thumb(*id) {
|
|
let [tw, th] = tex.size;
|
|
let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32);
|
|
let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s));
|
|
p.image(tex.tex.id(), fr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
|
if sel {
|
|
p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside);
|
|
}
|
|
}
|
|
if resp.clicked() {
|
|
let m = ui.input(|i| i.modifiers);
|
|
let mode = if m.command {
|
|
"toggle"
|
|
} else if m.shift {
|
|
"range"
|
|
} else {
|
|
"replace"
|
|
};
|
|
let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode}));
|
|
}
|
|
}
|
|
});
|
|
}
|