#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:
Nathaniel Ledford
2026-10-08 18:28:45 -04:00
co-authored by Claude Sonnet 5.5
parent 8a5ecebc18
commit e9fb4b08e1
5 changed files with 163 additions and 37 deletions
+3
View File
@@ -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(),
+44 -14
View File
@@ -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() {
+63 -21
View File
@@ -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() {
+6 -2
View File
@@ -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.
+47
View File
@@ -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");
}
}
}