mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 18:47:42 -06:00
#323: the render plan: drags keep the window, modes without windows render full size, limits count
Review findings on region::plan: - a drag at fit on a 4K or 5K display switched the window off (the whole-frame draft shrinks, the preview-source cap does not): the window decision now compares against the undrafted whole-frame render; - overlays, soft proofing and windows refused for reading too far (a spot or Auto Mask stroke) left the 1:1 view a canvas-sized upscale: those modes render the whole frame at the size it is drawn, as before windows (a refusal is remembered per photo, look and frame, and retried when any changes); - the 3840 and 5120 preview sizes had become no-ops: a limit above the preview source level is honoured; - windows were limited to 8192 px frames: they now reach the photo's own pixels (65536 px at most), so a medium-format photo is sharp at 1:1; - a pinch out from a deep zoom asked for an ever wider window every frame: windows already there are kept, magnified, until the gesture has been quiet. The unused region::needed is gone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
8a5ecebc18
commit
e9fb4b08e1
@@ -207,6 +207,8 @@ pub struct LightcraftApp {
|
||||
pub region_before_view: Option<region::RegionView>,
|
||||
/// The loupe's render sizes while a pinch or two-finger scroll runs.
|
||||
pub(crate) size_hold: region::SizeHold,
|
||||
/// (photo, look, window frame size) a window was refused for: it reads more than one render holds.
|
||||
pub(crate) window_refused: Option<(lightcraft_catalog::PhotoId, u64, usize)>,
|
||||
/// What the loupe drew last frame, in order (tests check layering).
|
||||
#[cfg(test)]
|
||||
pub(crate) draw_order: Vec<&'static str>,
|
||||
@@ -281,6 +283,7 @@ impl LightcraftApp {
|
||||
region_view: None,
|
||||
region_before_view: None,
|
||||
size_hold: Default::default(),
|
||||
window_refused: None,
|
||||
#[cfg(test)]
|
||||
draw_order: Vec::new(),
|
||||
region_tiles: Default::default(),
|
||||
|
||||
@@ -225,6 +225,10 @@ struct WindowCtx {
|
||||
texture_side: usize,
|
||||
crop_tool: bool,
|
||||
interacting: bool,
|
||||
/// Hash of the photo's develop settings.
|
||||
look: u64,
|
||||
/// A pinch or two-finger scroll is running: keep the windows there are.
|
||||
holding: bool,
|
||||
}
|
||||
|
||||
/// One side of the loupe that gets a window render.
|
||||
@@ -245,6 +249,11 @@ struct WindowView {
|
||||
/// drawn where it belongs. `None`: no window is wanted (or possible) for this view.
|
||||
fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Option<crate::region::RegionView> {
|
||||
let frame_edge = c.frame_edge?;
|
||||
// while a pinch or scroll runs the windows there are keep being drawn, magnified: no new one
|
||||
// per frame (zooming out would ask for ever wider ones)
|
||||
if c.holding {
|
||||
return if v.before { app.region_before_view } else { app.region_view }.filter(|w| w.photo == c.id);
|
||||
}
|
||||
let (fw, fh) =
|
||||
if c.aspect >= 1.0 { (frame_edge as f32, frame_edge as f32 / c.aspect) } else { (frame_edge as f32 * c.aspect, frame_edge as f32) };
|
||||
let (fw, fh) = (fw.round().max(1.0) as usize, fh.round().max(1.0) as usize);
|
||||
@@ -258,9 +267,15 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt
|
||||
);
|
||||
let win = crate::region::window_for(fw, fh, visible, crate::region::max_span(c.texture_side))?;
|
||||
let job = if v.before {
|
||||
app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool)?
|
||||
app.session.region_job_before(c.id, fw, fh, win, !c.crop_tool)
|
||||
} else {
|
||||
app.session.region_job(c.id, fw, fh, win, !c.crop_tool)?
|
||||
app.session.region_job(c.id, fw, fh, win, !c.crop_tool)
|
||||
};
|
||||
// refused: what the window reads (a spot's source, an Auto Mask stroke) is too far for one
|
||||
// render, so the whole frame is rendered at the drawn size instead (see `region::plan`)
|
||||
let Some(job) = job else {
|
||||
app.window_refused = Some((c.id, c.look, frame_edge));
|
||||
return None;
|
||||
};
|
||||
// a drag renders drafts of the window, as it does of the whole frame
|
||||
let job = if c.interacting { job.draft() } else { job };
|
||||
@@ -372,16 +387,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let native_long = native[0].max(native[1]);
|
||||
let drawn_long = target_rect.width().max(target_rect.height()) * ppp;
|
||||
// the whole frame at about canvas size; the zoomed part of it is a window render (below)
|
||||
let plan = crate::region::plan(
|
||||
&app.ui.settings,
|
||||
crate::region::ViewSizes {
|
||||
drawn_long,
|
||||
canvas_long: canvas.width().max(canvas.height()) * ppp,
|
||||
native_long,
|
||||
texture_side,
|
||||
draft_scale: scale,
|
||||
},
|
||||
);
|
||||
let look = d.hash64();
|
||||
// (modes that draw another picture over the loupe, or reads too far for one window, have the
|
||||
// whole frame rendered at the size it is drawn, as they did before windows)
|
||||
let sizes = crate::region::ViewSizes {
|
||||
drawn_long,
|
||||
canvas_long: canvas.width().max(canvas.height()) * ppp,
|
||||
native_long,
|
||||
texture_side,
|
||||
draft_scale: scale,
|
||||
windows: !app.ui.soft_proof && view_overlay(app, &d) == lightcraft_pipeline::Overlay::None,
|
||||
};
|
||||
let mut plan = crate::region::plan(&app.ui.settings, sizes);
|
||||
if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) {
|
||||
plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes });
|
||||
}
|
||||
// a pinch changes the zoom every frame: keep the sizes of before it until it is quiet
|
||||
let now = ui.input(|i| i.time);
|
||||
let plan = app.size_hold.apply(id, now, navigating, plan);
|
||||
@@ -451,8 +471,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
&& !app.ui.soft_proof
|
||||
&& view_overlay(app, &d) == lightcraft_pipeline::Overlay::None
|
||||
&& crate::region::same_aspect(display_aspect, aspect);
|
||||
let window_ctx =
|
||||
WindowCtx { id, frame_edge: plan.window_edge.filter(|_| plain_view), drawn_long, aspect, ppp, texture_side, crop_tool, interacting };
|
||||
let window_ctx = WindowCtx {
|
||||
id,
|
||||
frame_edge: plan.window_edge.filter(|_| plain_view),
|
||||
drawn_long,
|
||||
aspect,
|
||||
ppp,
|
||||
texture_side,
|
||||
crop_tool,
|
||||
interacting,
|
||||
look,
|
||||
holding: navigating || app.size_hold.holding(now),
|
||||
};
|
||||
// (slot, before side?, rect to draw in, rect it is requested for, area whose pixels show)
|
||||
let mut window_views: Vec<WindowView> = Vec::new();
|
||||
if window_ctx.frame_edge.is_some() {
|
||||
|
||||
@@ -72,6 +72,10 @@ pub struct ViewSizes {
|
||||
pub texture_side: usize,
|
||||
/// 1.0, or less while a slider is dragged (a draft of the whole-frame render).
|
||||
pub draft_scale: f32,
|
||||
/// Whether a window can be drawn over the whole-frame render (not with an overlay, soft
|
||||
/// proofing, or a read too far for one window): if not, the whole frame is rendered at the
|
||||
/// size it is drawn.
|
||||
pub windows: bool,
|
||||
}
|
||||
|
||||
/// What the loupe renders for a view: the whole frame at about canvas size, and, once the view is
|
||||
@@ -92,13 +96,20 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan {
|
||||
let canvas_long = if v.canvas_long.is_finite() { v.canvas_long.max(8.0) } else { 8.0 };
|
||||
let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long };
|
||||
let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 };
|
||||
let main_edge = settings
|
||||
.loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side)
|
||||
.min(lightcraft_engine::SourceLevel::Preview.max_edge());
|
||||
if !v.windows {
|
||||
let main_edge = settings.loupe_edge(drawn_long * scale, v.native_long, v.texture_side);
|
||||
return LoupePlan { main_edge, window_edge: None };
|
||||
}
|
||||
// a user who chose a size above the preview source level (3840, 5120) means it; otherwise the
|
||||
// original is not decoded for the whole-frame render
|
||||
let source_cap = if settings.preview_limit == 0 { lightcraft_engine::SourceLevel::Preview.max_edge() } else { settings.preview_limit as usize };
|
||||
let edge_at = |scale: f32| settings.loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side).min(source_cap);
|
||||
let main_edge = edge_at(scale);
|
||||
let frame_edge = settings.window_frame_edge(drawn_long, v.native_long);
|
||||
// a user who capped the preview size has chosen speed over sharpness at fit
|
||||
let fit = drawn_long <= canvas_long * 1.05;
|
||||
let window_edge = (frame_edge as f32 > main_edge as f32 / scale * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge);
|
||||
// (against the undrafted whole-frame render: a drag must not switch the window off and on)
|
||||
let window_edge = (frame_edge as f32 > edge_at(1.0) as f32 * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge);
|
||||
LoupePlan { main_edge, window_edge }
|
||||
}
|
||||
|
||||
@@ -166,12 +177,6 @@ pub fn same_aspect(shown: f32, frame: f32) -> bool {
|
||||
shown.is_finite() && frame.is_finite() && frame > 0.0 && (shown / frame - 1.0).abs() <= 0.02
|
||||
}
|
||||
|
||||
/// Whether a view drawn `drawn_long` pixels along its long edge needs a window render on top of
|
||||
/// the whole-frame render, which has only `rendered_long` of them.
|
||||
pub fn needed(drawn_long: f32, rendered_long: usize) -> bool {
|
||||
drawn_long.is_finite() && drawn_long > rendered_long as f32 * 1.02 + 1.0
|
||||
}
|
||||
|
||||
/// The window of a `full_w × full_h` frame to render so that `visible` — `(x0, y0, x1, y1)` in the
|
||||
/// frame's pixels, as much of the frame as is on screen — is covered: expanded by a margin,
|
||||
/// snapped outwards to the [`SNAP`] grid (margin: see [`margin_for`]) and clamped into the frame,
|
||||
@@ -209,7 +214,7 @@ mod tests {
|
||||
const BIG: usize = 16384;
|
||||
|
||||
fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes {
|
||||
ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0 }
|
||||
ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, windows: true }
|
||||
}
|
||||
|
||||
fn auto() -> AppSettings {
|
||||
@@ -291,6 +296,53 @@ mod tests {
|
||||
assert!(!same_aspect(1.5, 1.0) && !same_aspect(f32::NAN, 1.0) && !same_aspect(1.0, 0.0));
|
||||
}
|
||||
|
||||
// Given a drag at fit on a 4K or 5K display (where the fit view has a window), the window stays:
|
||||
// it is only the whole-frame draft that gets smaller
|
||||
#[test]
|
||||
fn a_drag_at_fit_on_a_large_display_keeps_the_window() {
|
||||
for drawn in [3300.0, 3840.0, 4096.0, 5120.0] {
|
||||
let still = plan(&auto(), sizes(drawn, drawn, 6000));
|
||||
let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(drawn, drawn, 6000) });
|
||||
assert!(still.window_edge.is_some(), "{drawn}: {still:?}");
|
||||
assert_eq!(drag.window_edge, still.window_edge, "{drawn}");
|
||||
}
|
||||
}
|
||||
|
||||
// Given a mode whose picture can't be a window (a mask overlay, soft proofing, a spot read too
|
||||
// far for one render), the whole-frame render is as big as the view, as it was before windows
|
||||
#[test]
|
||||
fn without_windows_the_whole_frame_is_rendered_at_the_drawn_size() {
|
||||
let p = plan(&auto(), ViewSizes { windows: false, ..sizes(6000.0, 2800.0, 6000) });
|
||||
assert_eq!((p.main_edge, p.window_edge), (6000, None));
|
||||
let fit = plan(&auto(), ViewSizes { windows: false, ..sizes(2800.0, 2800.0, 6000) });
|
||||
assert_eq!((fit.main_edge, fit.window_edge), (2816, None));
|
||||
let deep = plan(&auto(), ViewSizes { windows: false, ..sizes(48000.0, 2800.0, 6000) });
|
||||
assert_eq!(deep.main_edge, 6000, "never above the photo's own pixels");
|
||||
let small = plan(&auto(), ViewSizes { windows: false, texture_side: 2048, ..sizes(6000.0, 2800.0, 6000) });
|
||||
assert_eq!(small.main_edge, 2048);
|
||||
}
|
||||
|
||||
// Given a user who chose a preview size above the preview source level (3840 or 5120 on a big
|
||||
// display), the whole-frame render is that big
|
||||
#[test]
|
||||
fn a_large_preview_limit_is_honoured() {
|
||||
for limit in [3840u32, 5120] {
|
||||
let s = AppSettings { preview_limit: limit, ..Default::default() };
|
||||
let p = plan(&s, sizes(5120.0, 5120.0, 6000));
|
||||
assert_eq!(p.main_edge, limit as usize, "{limit}");
|
||||
assert_eq!(p.window_edge, None, "a fit view at the limit needs no window");
|
||||
}
|
||||
let s = AppSettings { preview_limit: 2560, ..Default::default() };
|
||||
assert_eq!(plan(&s, sizes(5120.0, 5120.0, 6000)).main_edge, 2560);
|
||||
}
|
||||
|
||||
// Given a medium format photo wider than 8192 px, 1:1 is its own pixels
|
||||
#[test]
|
||||
fn a_window_frame_can_be_as_big_as_the_photo() {
|
||||
let p = plan(&auto(), sizes(40000.0, 2800.0, 11648));
|
||||
assert_eq!(p.window_edge, Some(11648));
|
||||
}
|
||||
|
||||
// Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag)
|
||||
#[test]
|
||||
fn a_drag_at_fit_does_not_switch_the_window_on() {
|
||||
@@ -362,16 +414,6 @@ mod tests {
|
||||
assert!(p.main_edge >= 8);
|
||||
}
|
||||
|
||||
// Given a view drawn at 6000 px and a render of 6000, no window is needed; at 6000 vs 2560 it is
|
||||
#[test]
|
||||
fn a_window_is_needed_only_when_the_whole_frame_render_is_stretched() {
|
||||
assert!(!needed(6000.0, 6000));
|
||||
assert!(!needed(6100.0, 6000), "a couple of percent is not worth a second render");
|
||||
assert!(needed(6000.0, 2560));
|
||||
assert!(needed(48000.0, 8192));
|
||||
assert!(!needed(f32::NAN, 100) && !needed(f32::INFINITY, 100));
|
||||
}
|
||||
|
||||
// Given a visible area in the middle of a big frame, the window covers it with a margin, on the grid
|
||||
#[test]
|
||||
fn the_window_covers_the_visible_area_with_a_margin_on_the_grid() {
|
||||
|
||||
@@ -182,6 +182,10 @@ pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120];
|
||||
/// The most the loupe ever renders in one go (memory and GPU texture size), whatever the setting.
|
||||
pub const LOUPE_EDGE_CEILING: u32 = 8192;
|
||||
|
||||
/// The most a zoomed frame (the one a window is cut from) may be along its long edge: far more
|
||||
/// than the photos there are (a window holds only what is on screen, so the frame costs nothing).
|
||||
pub const WINDOW_FRAME_CEILING: usize = 65536;
|
||||
|
||||
/// The smallest GPU texture side the loupe plans for, whatever the host reports.
|
||||
pub const MIN_TEXTURE_SIDE: usize = 512;
|
||||
|
||||
@@ -223,8 +227,8 @@ impl AppSettings {
|
||||
/// GPU magnifies the window) or the ceiling. The preview size limit is not applied: it is about
|
||||
/// the whole-frame render (see [`crate::region::plan`]).
|
||||
pub fn window_frame_edge(&self, drawn_long: f32, native_long_edge: usize) -> usize {
|
||||
let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, LOUPE_EDGE_CEILING as f32) } as usize;
|
||||
wanted.min(native_long_edge.max(8)).min(LOUPE_EDGE_CEILING as usize)
|
||||
let wanted = if drawn_long.is_nan() { 8.0 } else { drawn_long.clamp(8.0, WINDOW_FRAME_CEILING as f32) } as usize;
|
||||
wanted.min(native_long_edge.max(8)).min(WINDOW_FRAME_CEILING)
|
||||
}
|
||||
|
||||
/// The edge Build Standard-Sized Previews uses.
|
||||
|
||||
@@ -555,4 +555,51 @@ mod in_the_loupe {
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.region_tiles.len(), 2, "{:?}", h.app.region_tiles.keys().collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
// Given soft proofing at 1:1 (a mode that draws one image, so no window), the whole-frame
|
||||
// render is as big as the view, not a canvas-sized upscale
|
||||
#[test]
|
||||
fn soft_proofing_at_one_to_one_renders_the_whole_frame_at_full_size() {
|
||||
let (mut h, native) = detail();
|
||||
h.app.ui.soft_proof = true;
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.region_view, None);
|
||||
assert_eq!(rendered_long_edge(&h), native);
|
||||
}
|
||||
|
||||
// Given a window the photo's spots make too big for one render (refused), the loupe falls back
|
||||
// to the whole frame at the drawn size, and tries windows again when the look changes
|
||||
#[test]
|
||||
fn a_refused_window_falls_back_to_a_full_size_render() {
|
||||
let (mut h, native) = detail();
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.region_view.is_some());
|
||||
let id = h.app.session.active().unwrap();
|
||||
let look = h.app.session.develop_of(id).unwrap().hash64();
|
||||
h.app.window_refused = Some((id, look, native));
|
||||
h.settle(SETTLE);
|
||||
assert_eq!(h.app.region_view, None);
|
||||
assert_eq!(rendered_long_edge(&h), native);
|
||||
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T);
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.region_view.is_some(), "a new look tries a window again");
|
||||
}
|
||||
|
||||
// Given a pinch out from 400 %, the window there is is kept (magnified, then shrunk) until the
|
||||
// pinch stops: a new, ever wider window every frame is not asked for
|
||||
#[test]
|
||||
fn pinching_out_asks_for_no_new_windows() {
|
||||
let (mut h, _) = detail();
|
||||
h.app.ui.zoom = crate::state::Zoom::Percent(400.0);
|
||||
h.settle(SETTLE);
|
||||
let key = h.app.region_view.expect("a window").key;
|
||||
let at = h.app.canvas_rect.unwrap().center();
|
||||
h.request("ui.move", json!({"x": at.x, "y": at.y}), T);
|
||||
for _ in 0..12 {
|
||||
h.request("ui.zoom", json!({"factor": 0.9}), T);
|
||||
assert_eq!(h.app.region_view.map(|v| v.key), Some(key), "the window of before the pinch");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user