mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
5 files changed
+111
-27
No files matched your search
@@ -204,6 +204,40 @@ fn mask_edge_refinement_does_not_depend_on_the_window() {
|
||||
check_noisy("refine", &s, MID, 3, 0.2);
|
||||
}
|
||||
|
||||
// Given a mask overlay (the Masking panel's view of the selected mask), a window of it matches the
|
||||
// same pixels of the whole render with that overlay, for every view and for drawn, AI and
|
||||
// refined shapes: the zoomed loupe can show the overlay as a window (issue #547)
|
||||
#[test]
|
||||
fn a_mask_overlay_window_matches_the_whole_render() {
|
||||
use crate::{MaskView, Overlay};
|
||||
use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
|
||||
use lightcraft_geom::Point;
|
||||
let shapes = [
|
||||
("radial", MaskShape::Radial { center: Point::new(0.5, 0.5), rx: 0.2, ry: 0.15, angle: 20.0, feather: 50.0, invert: false }, 0.0),
|
||||
("linear refined", MaskShape::Linear { start: Point::new(0.45, 0.5), end: Point::new(0.55, 0.5) }, 100.0),
|
||||
("sky", MaskShape::Sky, 0.0),
|
||||
];
|
||||
for (name, shape, refine) in shapes {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.masks.push(Mask {
|
||||
id: 3,
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
|
||||
adjust: LocalAdjustments { exposure: 1.0, ..Default::default() },
|
||||
refine,
|
||||
..Default::default()
|
||||
});
|
||||
for view in [MaskView::Color, MaskView::WhiteOnBlack, MaskView::ImageOnBlack] {
|
||||
let overlay = Overlay::Mask { id: 3, view, color: [255, 0, 0], opacity: 70 };
|
||||
let f = render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, ..RenderRequest::fit(W, H) }).image;
|
||||
let w =
|
||||
render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, window: Some(MID), ..RenderRequest::fit(W, H) }).image;
|
||||
let (max, mean) = compare(&f, &w, MID);
|
||||
// (refine edges differs by a rounding step, as in `mask_edge_refinement_does_not_depend_on_the_window`)
|
||||
assert!(max <= 3 && mean <= 0.25, "{name} {view:?}: max {max} (≤ 3), mean {mean:.3} (≤ 0.25)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Deep zoom: the wide stages' kernels are far wider than any margin -------------------------
|
||||
|
||||
/// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware
|
||||
|
||||
@@ -249,9 +249,17 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
|
||||
r
|
||||
}
|
||||
|
||||
/// Whether an overlay can be rendered on a region tile without changing what the tile means.
|
||||
/// Mask overlays are evaluated from the same mask alpha as the full render; the other diagnostic
|
||||
/// overlays either need whole-frame context or deliberately replace the loupe.
|
||||
pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool {
|
||||
matches!(overlay, lightcraft_pipeline::Overlay::None | lightcraft_pipeline::Overlay::Mask { .. })
|
||||
}
|
||||
|
||||
/// What a window render of the loupe needs to know about the view it belongs to.
|
||||
struct WindowCtx {
|
||||
id: PhotoId,
|
||||
overlay: lightcraft_pipeline::Overlay,
|
||||
/// Long edge of the frame the windows are cut from (`None`: no windows).
|
||||
frame_edge: Option<usize>,
|
||||
drawn_long: f32,
|
||||
@@ -317,10 +325,15 @@ fn request_window(app: &mut LightcraftApp, c: &WindowCtx, v: &WindowView) -> Opt
|
||||
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
|
||||
// a drag renders drafts of the window, as it does of the whole frame. The Before tile stays
|
||||
// plain; the After tile carries the same mask overlay as the whole-frame render.
|
||||
let job = if c.interacting { job.draft() } else { job };
|
||||
let overlay = if v.before { lightcraft_pipeline::Overlay::None } else { c.overlay };
|
||||
let job = if v.before { job } else { job.with_hidden_grading(app.ui.grading_hidden_for(c.id)) };
|
||||
let look = job.settings.hash64();
|
||||
let job = job.with_overlay(overlay);
|
||||
// The overlay is part of the tile's identity too, so changing mask view/colour never leaves a
|
||||
// stale region tile on screen while its replacement renders.
|
||||
let look = job.settings.hash64() ^ overlay.key().rotate_left(17);
|
||||
let view = crate::region::RegionView { photo: c.id, before: v.before, key: job.key, full: (fw, fh), window: win, settings: look };
|
||||
// windows of other photos, looks and zooms of this side can't be drawn any more
|
||||
app.region_tiles.retain(|(before, _), t| *before != v.before || t.is_current(c.id, (fw, fh), look, c.interacting));
|
||||
@@ -429,15 +442,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
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 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 overlay = view_overlay(app, &d);
|
||||
// Diagnostic views that need whole-frame context, soft proofing, or reads too far for one
|
||||
// window still render the whole frame at the size it is drawn. A mask overlay is window-safe.
|
||||
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,
|
||||
windows: !app.ui.soft_proof && overlay_supports_window(overlay),
|
||||
};
|
||||
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))) {
|
||||
@@ -456,7 +470,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let (sw, sh) = if aspect >= 1.0 { (se, (se as f32 / aspect) as usize) } else { ((se as f32 * aspect) as usize, se) };
|
||||
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 };
|
||||
let job = job.with_overlay(view_overlay(app, &d)).with_proof(app.ui.soft_proof.then_some(app.ui.proof));
|
||||
let job = job.with_overlay(overlay).with_proof(app.ui.soft_proof.then_some(app.ui.proof));
|
||||
let job = job.with_hidden_grading(app.ui.grading_hidden_for(id));
|
||||
app.renderer.request(Slot::Main, job, 100);
|
||||
}
|
||||
@@ -505,17 +519,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
app.renderer.request(Slot::Before, job, 90);
|
||||
}
|
||||
// zoomed past what the whole-frame render holds: also render the part on screen, at no more
|
||||
// than 100 % (the GPU magnifies beyond that), for each side that is shown. Not while hovering a
|
||||
// look or showing an overlay (those draw another image), nor where the shown picture isn't the
|
||||
// frame's own (an unsupported raw's embedded JPEG with another crop: window coordinates are
|
||||
// the frame's).
|
||||
let plain_view = hover_key.is_none()
|
||||
&& !app.ui.soft_proof
|
||||
&& view_overlay(app, &d) == lightcraft_pipeline::Overlay::None
|
||||
&& crate::region::same_aspect(display_aspect, aspect);
|
||||
// than 100 % (the GPU magnifies beyond that), for each side that is shown. Mask overlays use
|
||||
// the same window; diagnostic overlays that need whole-frame context do not.
|
||||
let windowable_view =
|
||||
hover_key.is_none() && !app.ui.soft_proof && overlay_supports_window(overlay) && crate::region::same_aspect(display_aspect, aspect);
|
||||
let window_ctx = WindowCtx {
|
||||
id,
|
||||
frame_edge: plan.window_edge.filter(|_| plain_view),
|
||||
overlay,
|
||||
frame_edge: plan.window_edge.filter(|_| windowable_view),
|
||||
drawn_long,
|
||||
aspect,
|
||||
ppp,
|
||||
|
||||
@@ -25,7 +25,7 @@ pub struct RegionView {
|
||||
pub key: u64,
|
||||
pub full: (usize, usize),
|
||||
pub window: PixelWindow,
|
||||
/// Hash of the develop settings it was rendered with.
|
||||
/// Hash of the render state it was rendered with (develop settings plus any window-safe overlay).
|
||||
pub settings: u64,
|
||||
}
|
||||
|
||||
@@ -72,9 +72,9 @@ 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.
|
||||
/// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay
|
||||
/// that needs whole-frame context, soft proofing, or a read too far for one window). Mask
|
||||
/// overlays are window-safe.
|
||||
pub windows: bool,
|
||||
}
|
||||
|
||||
@@ -97,9 +97,9 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan {
|
||||
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 };
|
||||
if !v.windows {
|
||||
// the whole frame at the size it is drawn (an overlay or soft proof has no window); while a
|
||||
// slider drags, a canvas-sized draft as with windows: a drag at 1:1 must not render the
|
||||
// photo's every pixel each frame
|
||||
// the whole frame at the size it is drawn (soft proof or a whole-frame diagnostic has no
|
||||
// window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must
|
||||
// not render the photo's every pixel each frame
|
||||
let long = if scale < 1.0 { drawn_long.min(canvas_long) } else { drawn_long };
|
||||
let main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side);
|
||||
return LoupePlan { main_edge, window_edge: None };
|
||||
@@ -281,8 +281,9 @@ mod tests {
|
||||
assert_eq!(drag.window_edge, still.window_edge);
|
||||
}
|
||||
|
||||
// Given no window (an overlay, soft proofing), the view at rest renders the whole frame at the
|
||||
// size it is drawn, but a slider drag drafts at the canvas size like the windowed path
|
||||
// Given no window (a whole-frame diagnostic or soft proofing), the view at rest renders the
|
||||
// whole frame at the size it is drawn, but a slider drag drafts at the canvas size like the
|
||||
// windowed path
|
||||
#[test]
|
||||
fn without_a_window_a_drag_drafts_at_the_canvas_size() {
|
||||
let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, ..sizes(24000.0, 2800.0, 6000) };
|
||||
@@ -331,8 +332,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// Given a mode whose picture can't be a window (soft proofing, a whole-frame diagnostic, or a
|
||||
// spot read too far for one render), the whole-frame render is as big as the view
|
||||
#[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) });
|
||||
|
||||
@@ -189,6 +189,44 @@ mod in_the_loupe {
|
||||
assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}");
|
||||
}
|
||||
|
||||
// Given a mask selected with its overlay shown at 1:1 (issue #547), the zoomed view keeps its
|
||||
// zoom and pan and stays a canvas-sized frame plus a native window carrying the overlay, as
|
||||
// without it, instead of a whole-frame render at the zoomed size; hiding or showing the
|
||||
// overlay swaps the window for one with the other look
|
||||
#[test]
|
||||
fn a_mask_overlay_at_one_to_one_is_a_window_and_keeps_zoom_and_pan() {
|
||||
let (mut h, native) = detail();
|
||||
for (command, params) in [("panel.masking", json!({})), ("mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2}))] {
|
||||
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
|
||||
assert_eq!(r["ok"], true, "{command}: {r}");
|
||||
}
|
||||
h.request("engine.execute", json!({"command": "view.zoom100"}), T);
|
||||
h.app.ui.pan = (0.3, 0.4);
|
||||
h.app.ui.mask_overlay = false;
|
||||
h.settle(SETTLE);
|
||||
let (zoom, pan) = (h.app.ui.zoom, h.app.ui.pan);
|
||||
assert_eq!(zoom, crate::state::Zoom::Percent(100.0));
|
||||
let plain = h.app.region_view.expect("a window render without the overlay");
|
||||
|
||||
h.app.ui.mask_overlay = true;
|
||||
h.settle(SETTLE);
|
||||
let id = h.app.session.active().expect("a photo");
|
||||
let d = h.app.session.develop_of(id).expect("settings");
|
||||
assert!(matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::Mask { .. }), "the mask overlay is shown");
|
||||
assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "showing the overlay keeps zoom and pan");
|
||||
let main = rendered_long_edge(&h);
|
||||
assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}");
|
||||
let shown = h.app.region_view.expect("a window render with the overlay");
|
||||
assert_eq!(shown.full.0.max(shown.full.1), native, "cut from the photo's own pixels");
|
||||
assert_eq!(shown.window, plain.window, "the same part of the photo");
|
||||
assert_ne!(shown.settings, plain.settings, "the overlay is part of the window's look");
|
||||
|
||||
h.app.ui.mask_overlay = false;
|
||||
h.settle(SETTLE);
|
||||
assert_eq!((h.app.ui.zoom, h.app.ui.pan), (zoom, pan), "hiding it keeps them too");
|
||||
assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window");
|
||||
}
|
||||
|
||||
// Given the user capped the preview at 1600 px, then 1:1 is rendered no larger than that
|
||||
#[test]
|
||||
fn an_explicit_limit_still_caps_one_to_one() {
|
||||
|
||||
@@ -302,7 +302,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | |
|
||||
| LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) |
|
||||
| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets |
|
||||
| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; no auto-show on hover |
|
||||
| LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover |
|
||||
| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode |
|
||||
| LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | |
|
||||
| LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | |
|
||||
|
||||
Reference in new issue
Block a user