Keep clarity's radius when the develop view zooms in

A frame fraction was taken of the render's short edge, and render_scale
folds the zoom into the region the render stands for. Zoomed to 1:1 on a
corner, clarity, texture and dehaze drew a quarter of the halo the export
gets, though the docs promise they preview honestly at any zoom.
RenderScale now carries the whole framed frame (within), and a frame
fraction is measured against it; at fit nothing changes.
This commit is contained in:
2026-09-27 17:34:16 -04:00
parent cfc1fea25a
commit e601bd806c
3 changed files with 57 additions and 9 deletions
+50 -2
View File
@@ -197,6 +197,10 @@ pub const DETAIL_BASE_UNIFORM_FIELDS: usize = 4;
pub struct RenderScale { pub struct RenderScale {
render: (u32, u32), render: (u32, u32),
full: (u32, u32), full: (u32, u32),
/// The whole framed photograph at source resolution: `full` before the
/// zoom and the tile were folded in. What a frame fraction is a fraction
/// of — see [`Self::frame_fraction`].
frame: (u32, u32),
} }
impl RenderScale { impl RenderScale {
@@ -208,9 +212,27 @@ impl RenderScale {
/// [`crate::EditGraph::render_scale`] works both out from the framing, and /// [`crate::EditGraph::render_scale`] works both out from the framing, and
/// is what a caller should normally use. /// is what a caller should normally use.
pub fn new(render: (u32, u32), full: (u32, u32)) -> Self { pub fn new(render: (u32, u32), full: (u32, u32)) -> Self {
let full = (full.0.max(1), full.1.max(1));
Self { Self {
render: (render.0.max(1), render.1.max(1)), render: (render.0.max(1), render.1.max(1)),
full: (full.0.max(1), full.1.max(1)), full,
frame: full,
}
}
/// TRACES: FR-DSP-1 | FR-DSP-2
/// The same scale, for a render that shows only part of a larger frame.
///
/// `frame` is the whole framed photograph at source resolution — the crop
/// folded in, the zoom and any tile not. A zoomed view and an export tile
/// both look at part of the frame, and a clarity radius is a fraction of
/// the *frame*, not of the part: measured against the part, zooming in
/// shrinks the halo to a fraction of what the file will get, and two
/// neighbouring tiles of an export would each draw their own.
pub fn within(self, frame: (u32, u32)) -> Self {
Self {
frame: (frame.0.max(1), frame.1.max(1)),
..self
} }
} }
@@ -259,8 +281,19 @@ impl RenderScale {
/// For the compositional family — clarity, texture, dehaze — and the same /// For the compositional family — clarity, texture, dehaze — and the same
/// unit `dr-gpu`'s mask rasteriser already converts feathers in. An edit /// unit `dr-gpu`'s mask rasteriser already converts feathers in. An edit
/// stored this way is resolution-independent by construction. /// stored this way is resolution-independent by construction.
///
/// Measured against the whole frame ([`Self::within`]), scaled by the
/// render's own short edge over the viewed region's. When the render shows
/// the whole frame the two sizes cancel and this is `fraction` of the
/// render's short edge exactly.
pub fn frame_fraction(&self, fraction: f32) -> f32 { pub fn frame_fraction(&self, fraction: f32) -> f32 {
fraction * self.render.0.min(self.render.1) as f32 let render = self.render.0.min(self.render.1) as f32;
let viewed = self.full.0.min(self.full.1) as f32;
let frame = self.frame.0.min(self.frame.1) as f32;
if self.frame == self.full {
return fraction * render;
}
fraction * render * (frame / viewed)
} }
/// Whether a radius stated in source pixels survives this render. /// Whether a radius stated in source pixels survives this render.
@@ -914,6 +947,21 @@ mod tests {
assert!((export.frame_fraction(0.01) - 40.0).abs() < 0.5); assert!((export.frame_fraction(0.01) - 40.0).abs() < 0.5);
} }
#[test]
fn a_frame_fraction_does_not_shrink_with_the_zoom_or_the_tile() {
// TRACES: FR-DSP-1 | FR-DSP-2
// A 6000×4000 frame. At fit in a 1500×1000 panel, 1% of it is 10
// render pixels; zoomed to 1:1 on a 1500×1000 corner of it, the same
// 1% is 40 — the 40 the file gets — and an export tile of that corner
// must say 40 too, or each tile draws its own halo and the seams show.
let fit = RenderScale::new((1500, 1000), (6000, 4000));
assert!((fit.frame_fraction(0.01) - 10.0).abs() < 1e-3);
let zoomed = RenderScale::new((1500, 1000), (1500, 1000)).within((6000, 4000));
assert!((zoomed.frame_fraction(0.01) - 40.0).abs() < 1e-3);
let tile = RenderScale::full((1024, 1024)).within((6000, 4000));
assert!((tile.frame_fraction(0.01) - 40.0).abs() < 1e-3);
}
#[test] #[test]
fn zooming_to_one_to_one_makes_the_preview_exact() { fn zooming_to_one_to_one_makes_the_preview_exact() {
// The reason there is no separate full-resolution preview path: the // The reason there is no separate full-resolution preview path: the
+1 -1
View File
@@ -952,7 +952,7 @@ impl EditGraph {
((fw as f32 * view.width).round() as u32).max(1), ((fw as f32 * view.width).round() as u32).max(1),
((fh as f32 * view.height).round() as u32).max(1), ((fh as f32 * view.height).round() as u32).max(1),
); );
crate::detail::RenderScale::new(render, full) crate::detail::RenderScale::new(render, full).within((fw, fh))
} }
/// TRACES: FR-DEV-3 | FR-DSP-1 /// TRACES: FR-DEV-3 | FR-DSP-1
File diff suppressed because one or more lines are too long