Rework Camera Raw filter preview and navigation (#781)

* Add Camera Raw source-resolution preview navigation

* Keep web preview responsive and discard stale refinement errors

* Preserve Alt wheel intent throughout smoothed Camera Raw zoom

* Specify refinement result type for the wasm fallback

* Bound sparse Camera Raw proxy domains and validate detail tests

* Document Camera Raw zoom controls with native screenshots

* Drop the Camera Raw zoom screenshots; keep the guide as docs/camera-raw-zoom.md

We don't commit sizable binary assets. Removes fit.png and actual-size.png,
their LICENSE.txt and ATTRIBUTION row, and moves the controls guide next to
docs/camera-raw-histogram.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Translate the Camera Raw full-resolution notice in de and pt-br

Main added German and Brazilian Portuguese catalogues after this branch
forked; the i18n coverage tests need the new label in both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abc2cb 2026-10-08 01:40:24 +03:00 committed by GitHub
parent c887ed94cc
commit f9479063be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1197 additions and 27 deletions

View File

@ -0,0 +1,440 @@
//! Full-resolution refinement of Camera Raw's interactive proxy. Native work is off the
//! UI thread; only one revision runs at a time, and obsolete results never replace new edits.
use egui::{Color32, ColorImage, Context, Pos2, Rect, TextureHandle, TextureOptions, pos2, vec2};
use photocraft_algo::camera_raw::CameraRaw;
use photocraft_geom::Rect as PixelRect;
use photocraft_raster::Surface;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{Receiver, TryRecvError},
};
#[derive(Clone)]
pub(crate) enum Coverage {
Selection(Surface),
Mask(photocraft_doc::LayerMask),
None,
}
type DetailResult = Result<(u64, Surface), String>;
type OverlayKey = (u64, bool, PixelRect, crate::camera_raw_scope_ui::ClippingMode, crate::theme::ThemeKind);
pub(crate) struct DetailPreview {
source: Surface,
area: PixelRect,
coverage: Coverage,
result: Option<(u64, Surface)>,
pending: Option<(u64, Receiver<DetailResult>)>,
cancel: Arc<AtomicBool>,
pub(crate) error: Option<String>,
failed_revision: Option<u64>,
crop: Option<(u64, bool, PixelRect)>,
texture: Option<TextureHandle>,
overlay_texture: Option<TextureHandle>,
overlay_key: Option<OverlayKey>,
pub(crate) ready: bool,
}
impl Drop for DetailPreview {
fn drop(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
impl DetailPreview {
pub(crate) fn new(source: Surface, area: PixelRect, coverage: Coverage) -> Self {
Self {
source,
area,
coverage,
result: None,
pending: None,
cancel: Arc::new(AtomicBool::new(false)),
error: None,
failed_revision: None,
crop: None,
texture: None,
overlay_texture: None,
overlay_key: None,
ready: false,
}
}
pub(crate) fn pending(&self) -> bool {
self.pending.is_some()
}
fn poll(&mut self, revision: u64) {
let Some((requested_revision, rx)) = &self.pending else { return };
let requested_revision = *requested_revision;
let result = match rx.try_recv() {
Ok(result) => result,
Err(TryRecvError::Empty) => return,
Err(TryRecvError::Disconnected) => Err("Camera Raw detail preview worker stopped".into()),
};
self.pending = None;
match result {
Ok((r, surface)) if r == revision => {
self.result = Some((r, surface));
self.crop = None;
self.error = None;
}
Ok(_) => {} // A parameter changed while the worker was developing this revision.
Err(e) => {
if requested_revision == revision {
self.error = Some(e);
}
self.failed_revision = Some(requested_revision);
}
}
}
fn request(&mut self, ctx: &Context, revision: u64, params: &CameraRaw) {
if self.pending.is_some() || self.result.as_ref().is_some_and(|(r, _)| *r == revision) || self.failed_revision == Some(revision) {
return;
}
self.result = None;
self.crop = None;
self.error = None;
// The engine's full-image pipeline needs multiple temporary float buffers. Never start
// an unbounded allocation just because a huge sparse document was zoomed into.
if self.area.width() as u64 * self.area.height() as u64 > 64_000_000 {
self.error = Some("Full-resolution Camera Raw preview is limited to 64 megapixels; the proxy remains available".into());
self.failed_revision = Some(revision);
return;
}
let source = self.source.clone();
let area = self.area;
let coverage = self.coverage.clone();
let mut params = params.clone();
params.pixel_scale = 1.0;
let cancel = self.cancel.clone();
let ctx = ctx.clone();
let (tx, rx) = std::sync::mpsc::channel::<DetailResult>();
let work = move || {
if cancel.load(Ordering::Relaxed) {
return;
}
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| develop(&source, area, &params, &coverage)))
.map(|surface| (revision, surface))
.map_err(|_| "Camera Raw detail preview failed; the proxy remains available".into());
if !cancel.load(Ordering::Relaxed) {
let _ = tx.send(result);
ctx.request_repaint();
}
};
#[cfg(not(target_arch = "wasm32"))]
{
// A closed dialog can leave one non-interruptible engine stage finishing. Serialize
// that work with the next dialog instead of multiplying its peak memory.
static WORKER: std::sync::Mutex<()> = std::sync::Mutex::new(());
match std::thread::Builder::new().name("camera-raw-preview".into()).spawn(move || {
let _guard = WORKER.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
work();
}) {
Ok(_) => self.pending = Some((revision, rx)),
Err(e) => {
self.error = Some(format!("Cannot start Camera Raw preview: {e}"));
self.failed_revision = Some(revision);
}
}
}
#[cfg(target_arch = "wasm32")]
{
// No browser worker/executor is available here. Preserve the interactive proxy
// instead of blocking its UI with the full-image engine pipeline.
drop(work);
drop(rx);
self.error = Some("Full-resolution filtered preview requires a native Camera Raw worker; the web preview keeps the proxy".into());
self.failed_revision = Some(revision);
}
}
/// Draw only the visible pixel rectangle. No full-image GPU texture and no GPU-size limit
/// on the source image; uploads are capped to the current viewport plus a one-pixel border.
pub(crate) fn paint(&mut self, ui: &egui::Ui, image: Rect, revision: u64, before: bool, params: &CameraRaw, needed: bool) -> bool {
self.ready = false;
self.poll(revision);
if !needed {
return false;
}
let Some(crop) = visible_pixels(image, ui.clip_rect(), self.area, ui.input(|i| i.max_texture_side)) else { return false };
let original = before || params.is_identity();
if !original {
self.request(ui.ctx(), revision, params);
self.poll(revision);
}
let surface = if original {
&self.source
} else if let Some((r, surface)) = &self.result
&& *r == revision
{
surface
} else {
return false;
};
let key = (if original { 0 } else { revision }, original, crop);
if self.crop != Some(key) {
let color = crop_image(surface, crop);
let options = TextureOptions { magnification: egui::TextureFilter::Nearest, ..TextureOptions::LINEAR };
match &mut self.texture {
Some(t) => t.set(color, options),
None => self.texture = Some(ui.ctx().load_texture("cr-detail-preview", color, options)),
}
self.crop = Some(key);
}
if let Some(texture) = &self.texture {
let scale = image.size() / vec2(self.area.width() as f32, self.area.height() as f32);
let screen = Rect::from_min_size(
image.min + vec2((crop.x0 - self.area.x0) as f32, (crop.y0 - self.area.y0) as f32) * scale,
vec2(crop.width() as f32, crop.height() as f32) * scale,
);
ui.painter().image(texture.id(), screen, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
self.ready = true;
}
true
}
pub(crate) fn sample(&self, p: [f32; 2], revision: u64, before: bool, params: &CameraRaw) -> Option<[f32; 4]> {
if !self.ready || !p.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)) {
return None;
}
let source = if before || params.is_identity() {
&self.source
} else if let Some((r, source)) = &self.result
&& *r == revision
{
source
} else {
return None;
};
let x = self.area.x0 + ((p[0] * self.area.width() as f32) as i32).min((self.area.width() - 1) as i32);
let y = self.area.y0 + ((p[1] * self.area.height() as f32) as i32).min((self.area.height() - 1) as i32);
Some(source.rgba(x, y))
}
pub(crate) fn overlay(&mut self, ui: &egui::Ui, image: Rect, revision: u64, mode: crate::camera_raw_scope_ui::ClippingMode) -> bool {
use crate::camera_raw_scope_ui::ClippingMode;
if !self.ready {
return false;
}
let Some((r, original, crop)) = self.crop else { return false };
let t = crate::theme::Tokens::get(ui.ctx());
let key = (r, original, crop, mode, t.kind);
if self.overlay_key != Some(key) {
let surface = if original {
&self.source
} else if let Some((r, surface)) = &self.result
&& *r == revision
{
surface
} else {
return false;
};
let mut row = vec![[0.0; 4]; crop.width() as usize];
let mut pixels = Vec::with_capacity(crop.width() as usize * crop.height() as usize);
for y in crop.y0..crop.y1 {
surface.read_rgba_into(PixelRect::new(crop.x0, y, crop.x1, y + 1), &mut row);
pixels.extend(row.iter().map(|p| {
if p[3] <= 0.0 || !p.iter().all(|v| v.is_finite()) {
return Color32::TRANSPARENT;
}
let (lo, hi) = photocraft_algo::histogram::clipping(*p);
match mode {
ClippingMode::ShadowChannels => t.histogram_color(7 ^ lo),
ClippingMode::HighlightChannels => {
if hi == 0 {
t.histogram_background()
} else {
t.histogram_color(hi)
}
}
ClippingMode::Warnings(w) => {
if w & 2 != 0 && hi != 0 {
t.histogram_color(1)
} else if w & 1 != 0 && lo != 0 {
t.histogram_color(4)
} else {
Color32::TRANSPARENT
}
}
ClippingMode::None => Color32::TRANSPARENT,
}
}));
}
let color = ColorImage::new([crop.width() as usize, crop.height() as usize], pixels);
match &mut self.overlay_texture {
Some(t) => t.set(color, TextureOptions::NEAREST),
None => self.overlay_texture = Some(ui.ctx().load_texture("cr-detail-clipping", color, TextureOptions::NEAREST)),
}
self.overlay_key = Some(key);
}
if let Some(texture) = &self.overlay_texture {
let scale = image.size() / vec2(self.area.width() as f32, self.area.height() as f32);
let screen = Rect::from_min_size(
image.min + vec2((crop.x0 - self.area.x0) as f32, (crop.y0 - self.area.y0) as f32) * scale,
vec2(crop.width() as f32, crop.height() as f32) * scale,
);
ui.painter().image(texture.id(), screen, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
}
true
}
}
fn develop(source: &Surface, area: PixelRect, params: &CameraRaw, coverage: &Coverage) -> Surface {
let mut result = photocraft_engine::lens_cmds::camera_raw_surface(source, area, params);
if matches!(coverage, Coverage::None) {
return result;
}
let width = area.width() as usize;
let channels = source.channels();
let mut original = Vec::new();
let mut row = Vec::new();
for y in area.y0..area.y1 {
let rect = PixelRect::new(area.x0, y, area.x1, y + 1);
source.read_region_into(rect, &mut original);
result.read_region_into(rect, &mut row);
for (x, (out, original)) in row.chunks_exact_mut(channels).zip(original.chunks_exact(channels)).take(width).enumerate() {
let x = area.x0 + x as i32;
let alpha = match coverage {
Coverage::Selection(mask) => mask.sample_channel(x, y, 0),
Coverage::Mask(mask) => mask.value(x, y),
Coverage::None => 1.0,
}
.clamp(0.0, 1.0);
for (out, original) in out.iter_mut().zip(original) {
*out = *original + (*out - *original) * alpha;
}
}
result.write_region(rect, &row);
}
result
}
fn visible_pixels(image: Rect, viewport: Rect, area: PixelRect, max_side: usize) -> Option<PixelRect> {
let visible = image.intersect(viewport);
if !image.is_finite() || image.width() <= 0.0 || image.height() <= 0.0 || !visible.is_positive() || area.is_empty() {
return None;
}
let scale = vec2(area.width() as f32, area.height() as f32) / image.size();
let lo = (visible.min - image.min) * scale;
let hi = (visible.max - image.min) * scale;
let crop = PixelRect::new(
area.x0.saturating_add((lo.x.floor() as i32).saturating_sub(1)),
area.y0.saturating_add((lo.y.floor() as i32).saturating_sub(1)),
area.x0.saturating_add((hi.x.ceil() as i32).saturating_add(1)),
area.y0.saturating_add((hi.y.ceil() as i32).saturating_add(1)),
)
.intersect(&area);
// Proxy is sufficient below its native scale; a larger intermediate should never be
// allocated if an unusually large viewport exceeds the device's texture limit.
(crop.width() as usize <= max_side && crop.height() as usize <= max_side && crop.width() as u64 * crop.height() as u64 <= 16_777_216).then_some(crop)
}
fn crop_image(surface: &Surface, area: PixelRect) -> ColorImage {
let width = area.width() as usize;
let mut row = vec![[0.0; 4]; width];
let mut pixels = Vec::with_capacity(width * area.height() as usize);
let enc = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
for y in area.y0..area.y1 {
surface.read_rgba_into(PixelRect::new(area.x0, y, area.x1, y + 1), &mut row);
pixels.extend(row.iter().map(|q| Color32::from_rgba_unmultiplied(enc(q[0]), enc(q[1]), enc(q[2]), enc(q[3]))));
}
ColorImage::new([width, area.height() as usize], pixels)
}
#[cfg(test)]
mod tests {
use super::*;
use photocraft_color::PixelFormat;
#[test]
fn refinement_uses_the_engine_at_source_resolution_and_respects_selection() {
let area = PixelRect::new(10, -5, 42, 19);
let mut source = Surface::new(PixelFormat::RGBA8);
let mut values = vec![0.0; 32 * 24 * 4];
for (i, p) in values.as_chunks_mut::<4>().0.iter_mut().enumerate() {
p.copy_from_slice(&[if i % 2 == 0 { 0.1 } else { 0.8 }, 0.3, 0.6, 1.0]);
}
source.write_region(area, &values);
let params = CameraRaw { exposure: 0.5, texture: 15.0, grain_amount: 30.0, ..Default::default() };
let expected = photocraft_engine::lens_cmds::camera_raw_surface(&source, area, &params);
assert_eq!(develop(&source, area, &params, &Coverage::None), expected);
let mask = Surface::with_default(PixelFormat::GRAY8, &[0.0]);
assert_eq!(develop(&source, area, &params, &Coverage::Selection(mask)), source);
}
#[test]
fn stale_refinements_and_failed_old_revisions_do_not_replace_new_settings() {
let source = Surface::with_default(PixelFormat::RGBA8, &[0.2, 0.4, 0.6, 1.0]);
let mut preview = DetailPreview::new(source.clone(), PixelRect::new(0, 0, 16, 16), Coverage::None);
let (tx, rx) = std::sync::mpsc::channel();
preview.pending = Some((1, rx));
tx.send(Ok((1, source.clone()))).unwrap();
preview.poll(2);
assert!(preview.result.is_none());
assert!(!preview.pending());
let (tx, rx) = std::sync::mpsc::channel();
preview.pending = Some((1, rx));
tx.send(Err("old failure".into())).unwrap();
preview.poll(2);
assert_eq!(preview.failed_revision, Some(1), "a failure must not suppress the new revision");
let (tx, rx) = std::sync::mpsc::channel();
preview.pending = Some((2, rx));
tx.send(Ok((2, source))).unwrap();
preview.poll(2);
assert_eq!(preview.result.as_ref().map(|(r, _)| *r), Some(2));
assert!(preview.error.is_none());
}
#[test]
fn full_resolution_sampling_and_crops_keep_fine_detail_at_every_supported_depth() {
use photocraft_color::{ColorMode, PixelFormat, SampleType};
for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {
for mode in [ColorMode::Rgb, ColorMode::Grayscale] {
let format = PixelFormat { mode, sample: depth, alpha: true };
let area = PixelRect::new(-12, 8, 28, 38);
let mut source = Surface::new(format);
let channels = format.channels();
let mut pixels = vec![0.0; 40 * 30 * channels];
for (i, pixel) in pixels.chunks_exact_mut(channels).enumerate() {
pixel.fill(if i % 2 == 0 { 0.1 } else { 0.9 });
pixel[channels - 1] = 1.0;
}
source.write_region(area, &pixels);
let params = CameraRaw { exposure: 0.5, sharpen_amount: 20.0, grain_amount: 30.0, ..Default::default() };
let full = develop(&source, area, &params, &Coverage::None);
let expected = photocraft_engine::lens_cmds::camera_raw_surface(&source, area, &params);
assert_eq!(full, expected);
let mut preview = DetailPreview::new(source, area, Coverage::None);
preview.result = Some((1, full));
preview.ready = true;
let dark = preview.sample([0.0, 0.0], 1, false, &params).unwrap();
let light = preview.sample([1.0 / 40.0, 0.0], 1, false, &params).unwrap();
assert!(light[0] - dark[0] > 0.5, "native alternating pixels survive: {mode:?}/{depth:?}");
assert_eq!(crop_image(&expected, PixelRect::new(-12, 8, -10, 9)).size, [2, 1]);
assert!(preview.sample([f32::NAN, 0.0], 1, false, &params).is_none());
assert!(preview.sample([0.5, 0.5], 2, false, &params).is_none());
}
}
}
#[test]
fn huge_sparse_images_keep_the_proxy_without_starting_an_unbounded_worker() {
let source = Surface::new(PixelFormat::RGBA8);
let mut preview = DetailPreview::new(source, PixelRect::new(0, 0, 8001, 8000), Coverage::None);
preview.request(&Context::default(), 1, &CameraRaw { exposure: 1.0, ..Default::default() });
assert!(!preview.pending());
assert!(preview.error.as_deref().unwrap().contains("64 megapixels"));
assert_eq!(preview.failed_revision, Some(1));
}
#[test]
fn zoom_uploads_only_visible_pixels_even_on_a_huge_offset_image() {
let area = PixelRect::new(-200, 10, 15800, 10010);
let image = Rect::from_min_size(pos2(-7800.0, -4800.0), vec2(16000.0, 10000.0));
let visible = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let crop = visible_pixels(image, visible, area, 4096).unwrap();
assert_eq!((crop.width(), crop.height()), (802, 602));
assert!(visible_pixels(image, Rect::from_min_size(Pos2::ZERO, vec2(8000.0, 6000.0)), area, 4096).is_none());
assert!(visible_pixels(Rect::NOTHING, visible, area, 4096).is_none());
}
}

View File

@ -0,0 +1,305 @@
//! Camera Raw navigation in source-pixel coordinates. No document edits.
use egui::{Key, Modifiers, PointerButton, Pos2, Rect, Response, Sense, Ui, Vec2, vec2};
use serde_json::Value;
use crate::{state::CameraRawPreviewState, widgets};
const MIN_ZOOM: f32 = 0.0001;
const MAX_ZOOM: f32 = 16.0;
impl CameraRawPreviewState {
pub(crate) fn scale(&self, viewport: Rect, size: Vec2) -> f32 {
self.zoom
.filter(|z| z.is_finite() && *z > 0.0)
.map(|z| z.clamp(MIN_ZOOM, MAX_ZOOM))
.unwrap_or_else(|| (viewport.width().max(1.0) / size.x.max(f32::EPSILON)).min(viewport.height().max(1.0) / size.y.max(f32::EPSILON)).min(MAX_ZOOM))
}
pub(crate) fn image_rect(&mut self, viewport: Rect, size: Vec2) -> Rect {
let extent = size.max(Vec2::splat(f32::EPSILON)) * self.scale(viewport, size);
for (center, (image, visible)) in self.center.iter_mut().zip([(extent.x, viewport.width()), (extent.y, viewport.height())]) {
let margin = (visible.max(1.0) / image.max(1.0) * 0.5).min(0.5);
*center = if center.is_finite() { center.clamp(margin, 1.0 - margin) } else { 0.5 };
}
Rect::from_min_size(viewport.center() - vec2(self.center[0] * extent.x, self.center[1] * extent.y), extent)
}
pub(crate) fn zoom_at(&mut self, zoom: f32, at: Pos2, viewport: Rect, size: Vec2) {
if !zoom.is_finite() || !at.x.is_finite() || !at.y.is_finite() {
return;
}
let old = self.image_rect(viewport, size);
let point = (at - old.min) / old.size();
self.zoom = Some(zoom.clamp(MIN_ZOOM, MAX_ZOOM));
let extent = size.max(Vec2::splat(f32::EPSILON)) * self.scale(viewport, size);
let center = point - (at - viewport.center()) / extent;
self.center = [center.x, center.y];
self.image_rect(viewport, size);
}
pub(crate) fn pan(&mut self, delta: Vec2, viewport: Rect, size: Vec2) {
if !delta.x.is_finite() || !delta.y.is_finite() {
return;
}
let rect = self.image_rect(viewport, size);
// Leaving fit mode on a hand drag would make later window resizes stop fitting.
self.center[0] -= delta.x / rect.width();
self.center[1] -= delta.y / rect.height();
self.image_rect(viewport, size);
}
pub(crate) fn fit(&mut self) {
self.zoom = None;
self.center = [0.5, 0.5];
}
}
/// Validate with the rest of the Camera Raw request before changing anything.
pub(crate) fn prepare(current: CameraRawPreviewState, value: &Value) -> Result<CameraRawPreviewState, String> {
let obj = value.as_object().ok_or("Camera Raw view must be an object")?;
let mut next = current;
for (key, value) in obj {
match key.as_str() {
"zoom" if value.as_str() == Some("fit") => next.fit(),
"zoom" => {
let z = value
.as_f64()
.filter(|z| z.is_finite() && (MIN_ZOOM as f64..=MAX_ZOOM as f64).contains(z))
.ok_or("Camera Raw zoom must be 'fit' or 0.0001–16 (1 = 100%)")?;
next.zoom = Some(z as f32);
}
"center" => {
let a = value.as_array().filter(|a| a.len() == 2).ok_or("Camera Raw center must be [u,v]")?;
let coordinate = |i| {
a.get(i)
.and_then(Value::as_f64)
.filter(|v| v.is_finite() && (0.0..=1.0).contains(v))
.map(|v| v as f32)
.ok_or("Camera Raw center must be finite coordinates in 0–1")
};
next.center = [coordinate(0)?, coordinate(1)?];
}
"hand" => next.hand = value.as_bool().ok_or("Camera Raw hand must be a boolean")?,
_ => return Err(format!("unknown Camera Raw view property {key}")),
}
}
if obj.get("zoom").and_then(Value::as_str) == Some("fit") {
next.fit();
}
Ok(next)
}
#[derive(Clone, Copy)]
struct ZoomCapture {
start: Pos2,
image: Rect,
box_zoom: bool,
}
/// One capture shared with the colour sampler. Space/middle drag always pans; S takes
/// precedence over the persistent Zoom/Hand tool, so a sampler click never zooms.
pub(crate) fn interact(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: Rect, size: Vec2, sampler: &mut bool) -> (Rect, Response, bool, Option<Rect>) {
let sampling = *sampler;
let response = ui.interact(viewport, ui.id().with("cr-sample-preview"), Sense::click_and_drag());
let modifiers = ui.input(|i| i.modifiers);
let temporary_zoom = modifiers.command && (state.hand || sampling);
let hand = !temporary_zoom && (state.hand && !sampling || ui.input(|i| i.key_down(Key::Space)));
let zoom_tool = temporary_zoom || !hand && !sampling;
let panning = hand || response.dragged_by(PointerButton::Middle);
let at = response.hover_pos().unwrap_or(viewport.center());
let rect = state.image_rect(viewport, size);
// Reuse the canvas wheel policy: retain the modifiers of the wheel event while egui
// smooths it, even if Alt is released before the final part of the notch arrives.
let wheel = crate::wheel_nav::read(ui.ctx(), false);
if response.hovered() {
ui.input_mut(|i| i.smooth_scroll_delta = Vec2::ZERO);
match wheel {
Some(crate::wheel_nav::Wheel::Zoom(factor)) => state.zoom_at(state.scale(viewport, size) * factor, at, viewport, size),
Some(crate::wheel_nav::Wheel::Pan(delta)) => state.pan(delta, viewport, size),
None => {}
}
response.clone().on_hover_cursor(if hand {
egui::CursorIcon::Grab
} else if !zoom_tool {
egui::CursorIcon::Crosshair
} else if modifiers.alt {
egui::CursorIcon::ZoomOut
} else {
egui::CursorIcon::ZoomIn
});
}
let capture_id = response.id.with("zoom-capture");
if response.drag_started_by(PointerButton::Primary) && zoom_tool {
let start = ui.input(|i| i.pointer.press_origin()).unwrap_or(at);
if rect.contains(start) {
ui.ctx().data_mut(|data| data.insert_temp(capture_id, ZoomCapture { start, image: rect, box_zoom: modifiers.command && !temporary_zoom }));
}
}
let capture = ui.ctx().data(|data| data.get_temp::<ZoomCapture>(capture_id));
let mut zoom_box = None;
if panning && (response.dragged_by(PointerButton::Primary) || response.dragged_by(PointerButton::Middle)) {
state.pan(response.drag_delta(), viewport, size);
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if response.dragged_by(PointerButton::Primary)
&& let Some(capture) = capture
{
if capture.box_zoom {
if let Some(end) = response.interact_pointer_pos() {
let region = Rect::from_two_pos(capture.start, end).intersect(viewport);
zoom_box = Some(region);
}
} else {
// The anchor is the initial press, not the moving pointer; reversing a scrub
// follows the same detail instead of drifting across the image.
state.zoom_at(state.scale(viewport, size) * (response.drag_delta().x * 0.01).clamp(-1.0, 1.0).exp(), capture.start, viewport, size);
}
} else if response.double_clicked() && hand {
state.fit();
} else if response.clicked() && zoom_tool && rect.contains(at) {
if modifiers.command && modifiers.shift {
state.zoom_at(1.0, at, viewport, size);
} else if modifiers.alt {
state.zoom_at(state.scale(viewport, size) / 1.25, at, viewport, size);
} else if state.zoom.is_none() {
state.zoom_at(1.0, at, viewport, size);
} else {
state.fit();
}
}
if response.drag_stopped_by(PointerButton::Primary) {
if let Some(capture) = capture.filter(|c| c.box_zoom)
&& let Some(end) = response.interact_pointer_pos()
{
let region = Rect::from_two_pos(capture.start, end).intersect(viewport);
if region.width() >= 4.0 && region.height() >= 4.0 {
let point = (region.center() - capture.image.min) / capture.image.size();
state.zoom = Some(
(capture.image.width() / size.x * (viewport.width() / region.width()).min(viewport.height() / region.height())).clamp(MIN_ZOOM, MAX_ZOOM),
);
state.center = [point.x, point.y];
}
}
ui.ctx().data_mut(|data| data.remove::<ZoomCapture>(capture_id));
}
response.context_menu(|ui| {
if ui.button(tl!("Fit in View")).clicked() {
state.fit();
ui.close();
}
if ui.button(tl!("100%")).clicked() {
state.zoom_at(1.0, at, viewport, size);
ui.close();
}
ui.menu_button(tl!("Zoom Tool"), |ui| {
for percent in [25.0, 50.0, 100.0, 200.0, 400.0, 800.0, 1600.0] {
if ui.button(format!("{percent:.0}%")).clicked() {
state.zoom_at(percent / 100.0, at, viewport, size);
ui.close();
}
}
});
});
if !ui.ctx().text_edit_focused() {
ui.input_mut(|i| {
if i.consume_key(Modifiers::COMMAND | Modifiers::ALT, Key::Num0) {
state.zoom_at(1.0, viewport.center(), viewport, size);
} else if i.consume_key(Modifiers::COMMAND, Key::Num0) {
state.fit();
}
if i.consume_key(Modifiers::COMMAND, Key::Plus) || i.consume_key(Modifiers::COMMAND, Key::Equals) {
state.zoom_at(state.scale(viewport, size) * 1.25, viewport.center(), viewport, size);
}
if i.consume_key(Modifiers::COMMAND, Key::Minus) {
state.zoom_at(state.scale(viewport, size) / 1.25, viewport.center(), viewport, size);
}
if i.consume_key(Modifiers::NONE, Key::H) {
state.hand = true;
*sampler = false;
}
if i.consume_key(Modifiers::NONE, Key::Z) {
state.hand = false;
*sampler = false;
}
});
}
(state.image_rect(viewport, size), response, panning || temporary_zoom, zoom_box)
}
pub(crate) fn toolbar(ui: &mut Ui, state: &mut CameraRawPreviewState, viewport: Rect, size: Vec2, sampler: &mut bool) {
let zoom_tool = crate::icons::button(ui, "zoom-in", 28.0, !state.hand && !*sampler, tl!("Zoom Tool"));
zoom_tool.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Zoom Tool")));
if zoom_tool.double_clicked() {
state.fit();
}
if zoom_tool.clicked() {
state.hand = false;
*sampler = false;
}
let hand_tool = crate::icons::button(ui, "hand", 28.0, state.hand && !*sampler, tl!("Hand Tool"));
hand_tool.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Hand Tool")));
if hand_tool.double_clicked() {
state.fit();
}
if hand_tool.clicked() {
state.hand = true;
*sampler = false;
}
if widgets::secondary_button(ui, tl!("Fit in View"), 84.0).clicked() {
state.fit();
}
if widgets::secondary_button(ui, tl!("100%"), 52.0).clicked() {
state.zoom_at(1.0, viewport.center(), viewport, size);
}
let mut zoom = state.zoom;
let mut options = vec![(None, tl!("Fit in View"))];
let labels = ["25%", "50%", "100%", "200%", "400%", "800%", "1600%"];
for (z, label) in [0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0].into_iter().zip(labels) {
options.push((Some(z), label));
}
let current_label = format!("{:.1}%", state.scale(viewport, size) * 100.0);
if !options.iter().any(|(z, _)| *z == zoom) {
options.push((zoom, &current_label));
}
if widgets::dropdown(ui, "cr-preview-zoom", &mut zoom, &options, 80.0) {
match zoom {
Some(z) => state.zoom_at(z, viewport.center(), viewport, size),
None => state.fit(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::pos2;
#[test]
fn zoom_preserves_the_point_under_the_pointer_and_pan_clamps() {
let viewport = Rect::from_min_size(pos2(10.0, 20.0), vec2(800.0, 600.0));
let size = vec2(1600.0, 1200.0);
let mut view = CameraRawPreviewState::default();
let at = pos2(310.0, 270.0);
let initial = view.image_rect(viewport, size);
let point = (at - initial.min) / initial.size();
view.zoom_at(1.0, at, viewport, size);
let enlarged = view.image_rect(viewport, size);
assert!(((at - enlarged.min) / enlarged.size() - point).length() < 1e-5);
view.pan(vec2(1e9, -1e9), viewport, size);
let panned = view.image_rect(viewport, size);
assert!(panned.contains_rect(viewport));
view.fit();
assert_eq!(view.image_rect(viewport, size), initial);
}
#[test]
fn tiny_images_nonfinite_state_and_resize_stay_centered_and_finite() {
let viewport = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 400.0));
let size = vec2(40.0, 20.0);
let mut view = CameraRawPreviewState { zoom: Some(f32::NAN), center: [f32::INFINITY, f32::NAN], hand: true };
assert!(view.image_rect(viewport, size).is_finite());
view.zoom_at(1.0, pos2(100.0, 100.0), viewport, size);
assert_eq!(view.image_rect(viewport, size).center(), viewport.center());
let before = view;
view.zoom_at(f32::INFINITY, viewport.center(), viewport, size);
view.pan(vec2(f32::NAN, 1.0), viewport, size);
assert_eq!(view, before);
}
}

View File

@ -100,6 +100,7 @@ pub(crate) struct ScopeView {
pub(crate) ms: f64,
pub(crate) revision: u64,
pub(crate) overlay_tex: Option<TextureHandle>,
pub(crate) overlay_detail: bool,
pub(crate) overlay_key: Option<(u64, bool, ClippingMode, crate::theme::ThemeKind)>,
pub(crate) floating_dragged: bool,
pub(crate) geometry_requested: bool,
@ -141,7 +142,10 @@ impl CameraRawDialog {
}
let x = ((p[0] * self.pw as f32) as usize).min(self.pw - 1);
let y = ((p[1] * self.ph as f32) as usize).min(self.ph - 1);
let q = *self.pixels().get(y.checked_mul(self.pw)?.checked_add(x)?)?;
let q = self
.detail
.sample(p, self.preview_revision, self.show_before, &self.params)
.or_else(|| self.pixels().get(y.checked_mul(self.pw)?.checked_add(x)?).copied())?;
(q[3] > 0.0 && q.iter().all(|v| v.is_finite())).then_some(q)
}
pub(crate) fn readout(&self, p: [f32; 2], lab: bool) -> Option<Value> {
@ -559,17 +563,16 @@ pub(crate) fn floating(ctx: &egui::Context, d: &mut CameraRawDialog, view: &mut
}
}
pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, rect: Rect) {
pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut CameraRawScopeState, rect: Rect, resp: &egui::Response, panning: bool) {
let t = Tokens::get(ui.ctx());
let resp = ui.interact(rect, ui.id().with("cr-sample-preview"), Sense::click_and_drag());
if let Some(p) = resp.hover_pos() {
if let Some(p) = resp.hover_pos().filter(|p| rect.contains(*p)) {
d.scope.pointer_sample =
Some([((p.x - rect.left()) / rect.width().max(1.0)).clamp(0.0, 1.0), ((p.y - rect.top()) / rect.height().max(1.0)).clamp(0.0, 1.0)]);
} else {
d.scope.pointer_sample = None;
}
let to_screen = |p: [f32; 2]| pos2(rect.left() + p[0] * rect.width(), rect.top() + p[1] * rect.height());
if view.sampler_tool {
if view.sampler_tool && !panning {
resp.clone().on_hover_cursor(egui::CursorIcon::Crosshair);
if resp.clicked()
&& let Some(p) = d.scope.pointer_sample
@ -597,6 +600,9 @@ pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut Cam
d.scope.sampler_drag = None;
}
}
if panning {
d.scope.sampler_drag = None;
}
let alt_zone = d.scope.tone_drag.map(|d| d.0).or(d.scope.alt_tone);
let mode = if ui.input(|i| i.modifiers.alt && i.pointer.primary_down()) && alt_zone.is_some() {
if matches!(alt_zone, Some(ToneZone::Blacks | ToneZone::Shadows)) { ClippingMode::ShadowChannels } else { ClippingMode::HighlightChannels }
@ -606,8 +612,12 @@ pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut Cam
ClippingMode::None
};
let key = (d.preview_revision, d.show_before, mode, t.kind);
if mode != ClippingMode::None {
if d.scope.overlay_key != Some(key) {
if mode != ClippingMode::None && d.detail.overlay(ui, rect, d.preview_revision, mode) {
d.scope.overlay_key = Some(key);
d.scope.overlay_detail = true;
} else if mode != ClippingMode::None {
if d.scope.overlay_key != Some(key) || d.scope.overlay_detail {
d.scope.overlay_detail = false;
let colors = d
.pixels()
.iter()
@ -649,9 +659,10 @@ pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut Cam
ui.painter().image(tex.id(), rect, Rect::from_min_max(Pos2::ZERO, pos2(1.0, 1.0)), Color32::WHITE);
}
}
let visible = rect.intersect(ui.clip_rect());
if !view.samplers.is_empty() {
ui.painter().rect_filled(
Rect::from_min_size(rect.min, vec2(rect.width().min(330.0), view.samplers.len().min(MAX_SAMPLERS) as f32 * 16.0 + 12.0)),
Rect::from_min_size(visible.min, vec2(visible.width().min(330.0), view.samplers.len().min(MAX_SAMPLERS) as f32 * 16.0 + 12.0)),
t.radius_sm,
t.dock.gamma_multiply(0.92),
);
@ -662,7 +673,7 @@ pub(crate) fn preview(ui: &mut egui::Ui, d: &mut CameraRawDialog, view: &mut Cam
ui.painter().line_segment([q - vec2(8.0, 0.0), q + vec2(8.0, 0.0)], Stroke::new(1.0, t.text));
ui.painter().line_segment([q - vec2(0.0, 8.0), q + vec2(0.0, 8.0)], Stroke::new(1.0, t.text));
let text = format!("{} {}", i + 1, readout_text(d.readout(*p, view.lab)));
ui.painter().text(pos2(rect.left() + 6.0, rect.top() + 6.0 + i as f32 * 16.0), Align2::LEFT_TOP, text, crate::theme::mono(11.0), t.text);
ui.painter().text(pos2(visible.left() + 6.0, visible.top() + 6.0 + i as f32 * 16.0), Align2::LEFT_TOP, text, crate::theme::mono(11.0), t.text);
ui.painter().text(q + vec2(7.0, 7.0), Align2::LEFT_TOP, format!("{}", i + 1), crate::theme::mono(11.0), t.text);
}
}

View File

@ -41,6 +41,10 @@ pub struct CameraRawDialog {
pub(crate) pw: usize,
pub(crate) ph: usize,
full_w: usize,
full_h: usize,
preview_ppp: f32,
pub(crate) detail: super::camera_raw_detail_ui::DetailPreview,
pub(crate) viewport: Option<ERect>,
pub(crate) float: bool,
tex: Option<TextureHandle>,
before_tex: Option<TextureHandle>,
@ -59,7 +63,7 @@ pub struct CameraRawDialog {
}
impl CameraRawDialog {
pub fn describe(&self, view: &crate::state::CameraRawScopeState) -> Value {
pub fn describe(&self, view: &crate::state::CameraRawScopeState, navigation: &crate::state::CameraRawPreviewState) -> Value {
let h = self.histogram();
let mut result = json!({"layer": self.layer.0, "params": serde_json::to_value(&self.params).unwrap_or(Value::Null), "proxy": [self.pw, self.ph],
"renderMs": self.render_ms, "histogramMs": self.histogram_ms, "previewRevision": self.preview_revision, "before": self.show_before,
@ -70,6 +74,13 @@ impl CameraRawDialog {
Target::SmartFilter(index) => Some(index),
Target::NewFilter => None,
});
result["view"] = json!(navigation);
result["sourceSize"] = json!([self.full_w, self.full_h]);
result["previewApproximate"] = json!(!self.detail.ready && (self.pw != self.full_w || self.ph != self.full_h));
result["detailPending"] = json!(self.detail.pending());
result["detailError"] = json!(self.detail.error);
result["viewportRect"] = json!(self.viewport.map(|r| [r.left(), r.top(), r.right(), r.bottom()]));
result["zoom"] = json!(self.viewport.map(|r| navigation.scale(r, vec2(self.full_w as f32, self.full_h as f32) / self.preview_ppp)));
result["scope"] = serde_json::to_value(view).unwrap_or(Value::Null);
result["hasScopeSelection"] = json!(self.selection.is_some());
result["vectorscope"] = json!(
@ -204,9 +215,18 @@ fn open_pixels(
let st = app.session.active().ok_or("no document")?;
let canvas = st.doc.bounds();
let name = st.doc.layer(layer).map(|l| l.name.clone()).unwrap_or_default();
let area = surf.content_bounds().intersect(&canvas);
let area = if area.is_empty() { canvas } else { area };
let (w, h) = (area.width() as usize, area.height() as usize);
// Use the engine filter domain, including pixels outside the canvas: spatial Camera Raw
// stages (vignette, grain, local contrast) must agree with the committed result.
let area = canvas.union(&surf.content_bounds());
let width = area.x1.checked_sub(area.x0).filter(|n| (1..=1_048_576).contains(n));
let height = area.y1.checked_sub(area.y0).filter(|n| (1..=1_048_576).contains(n));
let (Some(w), Some(h)) = (width, height) else {
return Err("Camera Raw preview bounds are too large".into());
};
let (w, h) = (w as usize, h as usize);
if w.checked_mul(h).is_none_or(|pixels| pixels > 512_000_000) {
return Err("Camera Raw preview bounds are too large".into());
}
let k = w.max(h).div_ceil(PROXY_SIDE).max(1);
let (pw, ph) = (w.div_ceil(k), h.div_ceil(k));
let mut proxy = vec![[0.0f32; 4]; pw * ph];
@ -250,6 +270,7 @@ fn open_pixels(
{
app.ui.camera_raw_scope = view;
}
app.ui.camera_raw_preview = Default::default();
app.ui.camera_raw_scope.samplers.clear();
// Keep Camera Raw compact on open; vectorscope is an opt-in context-menu view.
app.ui.camera_raw_scope.vectorscope = false;
@ -276,6 +297,20 @@ fn open_pixels(
pw,
ph,
full_w: w,
full_h: h,
preview_ppp: ctx.pixels_per_point(),
detail: super::camera_raw_detail_ui::DetailPreview::new(
surf,
area,
match coverage {
Coverage::Selection => {
st.doc.selection.clone().map(super::camera_raw_detail_ui::Coverage::Selection).unwrap_or(super::camera_raw_detail_ui::Coverage::None)
}
Coverage::Mask(mask) => super::camera_raw_detail_ui::Coverage::Mask(mask),
Coverage::None => super::camera_raw_detail_ui::Coverage::None,
},
),
viewport: None,
float,
tex: None,
before_tex: None,
@ -300,7 +335,9 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
return Some(Err("Camera Raw params must be an object".into()));
};
if fields.is_empty() {
return Some(open(app, ctx).map(|_| app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null)));
return Some(
open(app, ctx).map(|_| app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap_or(Value::Null)),
);
}
if let Some(sf) = params.get("smartFilter") {
let opened = (|| {
@ -310,7 +347,7 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
let layer = sf.get("layer").and_then(Value::as_u64).ok_or("smartFilter.layer must be a layer id")?;
let index = sf.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or("smartFilter.index must be a filter index")?;
open_smart_filter(app, ctx, LayerId(layer), index)?;
Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null))
Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap_or(Value::Null))
})();
return Some(opened);
}
@ -320,7 +357,7 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
};
for (key, value) in fields {
match key.as_str() {
"set" | "scope" => {}
"set" | "scope" | "view" => {}
"before" | "commit" | "cancel" if value.is_boolean() => {}
"before" | "commit" | "cancel" => return Some(Err(format!("Camera Raw {key} must be a boolean"))),
_ => return Some(Err(format!("unknown Camera Raw ui property {key}"))),
@ -331,6 +368,7 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
}
let was_closed = app.camera_raw.is_none();
let previous_view = app.ui.camera_raw_scope.clone();
let previous_navigation = app.ui.camera_raw_preview;
if was_closed && let Err(e) = open(app, ctx) {
return Some(Err(e));
}
@ -338,6 +376,7 @@ pub fn menu(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, params: &Val
if was_closed && result.as_ref().is_some_and(Result::is_err) {
app.camera_raw = None;
app.ui.camera_raw_scope = previous_view;
app.ui.camera_raw_preview = previous_navigation;
}
result
}
@ -375,7 +414,17 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti
},
None => None,
};
let navigation = match ui.get("view") {
Some(view) => match super::camera_raw_preview_ui::prepare(app.ui.camera_raw_preview, view) {
Ok(next) => Some(next),
Err(e) => return Some(Err(e)),
},
None => None,
};
let d = app.camera_raw.as_mut()?;
if let Some(view) = navigation {
app.ui.camera_raw_preview = view;
}
if d.params.point_curve != next_params.point_curve {
d.curve_state = Default::default();
}
@ -403,7 +452,7 @@ fn menu_update(app: &mut PhotocraftApp, ctx: &egui::Context, ui: &Value) -> Opti
if ui.get("commit").and_then(Value::as_bool) == Some(true) {
return Some(commit(app));
}
Some(Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)).unwrap_or(Value::Null)))
Some(Ok(app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)).unwrap_or(Value::Null)))
}
fn commit(app: &mut PhotocraftApp) -> Result<Value, String> {
@ -537,17 +586,45 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
let footer_h = 48.0;
let body = ERect::from_min_max(pos2(full.left(), title.bottom()), pos2(full.right(), full.bottom() - footer_h));
let scope = &mut app.ui.camera_raw_scope;
// Preview.
let view = ERect::from_min_max(body.min, pos2(body.right() - PANEL_W, body.bottom())).shrink(16.0);
painter.rect_filled(view, 0.0, t.canvas);
// Clip both pixels and scope overlays before painting the settings dock/footer.
let preview_body = ERect::from_min_max(body.min, pos2((body.right() - PANEL_W).max(body.left() + 1.0), body.bottom()));
let view = ERect::from_min_max(
preview_body.min + vec2(16.0, 16.0),
pos2((preview_body.right() - 16.0).max(preview_body.left() + 17.0), (preview_body.bottom() - 48.0).max(preview_body.top() + 17.0)),
);
d.viewport = Some(view);
d.preview_ppp = ctx.pixels_per_point();
let size = vec2(d.full_w as f32, d.full_h as f32) / ctx.pixels_per_point();
let navigation = &mut app.ui.camera_raw_preview;
let mut preview = ui.new_child(egui::UiBuilder::new().max_rect(view));
preview.set_clip_rect(view.intersect(ui.clip_rect()));
preview.painter().rect_filled(view, 0.0, t.canvas);
let (r, response, panning, zoom_box) = super::camera_raw_preview_ui::interact(&mut preview, navigation, view, size, &mut scope.sampler_tool);
let tex = if d.show_before { d.before_tex.as_ref() } else { d.tex.as_ref() };
if let Some(tex) = tex {
let s = (view.width() / d.pw as f32).min(view.height() / d.ph as f32);
let r = ERect::from_center_size(view.center(), vec2(d.pw as f32 * s, d.ph as f32 * s));
widgets::checker(&painter, r, 8.0);
painter.image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
// Never tile a checker across the offscreen extent of a highly zoomed image.
widgets::checker(preview.painter(), r.intersect(view), 8.0);
preview.painter().image(tex.id(), r, ERect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
let detail_needed = r.width() * ctx.pixels_per_point() > d.pw as f32 || r.height() * ctx.pixels_per_point() > d.ph as f32;
d.detail.paint(&preview, r, d.preview_revision, d.show_before, &d.params, detail_needed);
if d.detail.pending() {
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
d.scope.preview_rect = Some(r);
super::camera_raw_scope_ui::preview(ui, d, scope, r);
super::camera_raw_scope_ui::preview(&mut preview, d, scope, r, &response, panning);
}
if let Some(region) = zoom_box {
preview.painter().rect_stroke(region, 0.0, egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
}
let toolbar = ERect::from_min_max(pos2(preview_body.left() + 16.0, view.bottom() + 8.0), preview_body.right_bottom() - vec2(16.0, 4.0));
let mut nav_ui = ui.new_child(egui::UiBuilder::new().max_rect(toolbar).layout(egui::Layout::left_to_right(egui::Align::Center)));
nav_ui.set_clip_rect(preview_body.intersect(ui.clip_rect()));
super::camera_raw_preview_ui::toolbar(&mut nav_ui, navigation, view, size, &mut scope.sampler_tool);
if d.detail.pending() {
nav_ui.spinner();
}
if let Some(error) = &d.detail.error {
nav_ui.label(egui::RichText::new(tl!("Full-resolution preview unavailable")).color(t.text_faint)).on_hover_text(error);
}
// Panels.
let right = ERect::from_min_max(pos2(body.right() - PANEL_W, body.top()), body.max);
@ -799,6 +876,20 @@ mod tests {
assert!(menu(&mut app, &ctx, "filter.cameraRaw", &json!({"smartFilter": {"layer": "x"}})).unwrap().is_err());
}
#[test]
fn distant_sparse_pixels_cannot_trigger_an_unbounded_proxy_scan() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
let ctx = egui::Context::default();
app.run("file.new", json!({"width":64,"height":48})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
let layer = app.session.active().unwrap().active_layer.unwrap();
let mut source = photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8);
source.write_region(Rect::new(30_000, 30_000, 30_001, 30_001), &[0.5, 0.5, 0.5, 1.0]);
let result = open_pixels(&mut app, &ctx, layer, source, CameraRaw::default(), Coverage::None, Target::NewFilter);
assert!(result.unwrap_err().contains("bounds are too large"));
assert!(app.camera_raw.is_none());
}
#[test]
fn dialog_drives_the_engine() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());

View File

@ -640,7 +640,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
"brush": {"size": app.session.tools.brush.size, "hardness": app.session.tools.brush.hardness, "opacity": app.session.tools.brush.opacity},
"distort": app.distort.describe(),
"jobs": crate::jobs_ui::inspect(app),
"cameraRaw": app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope)),
"cameraRaw": app.camera_raw.as_ref().map(|d| d.describe(&app.ui.camera_raw_scope, &app.ui.camera_raw_preview)),
})
}

View File

@ -2065,6 +2065,7 @@ cameraRaw Color Barvy
Patch: Záplata:
Lasso around an area, then drag the selection Obtáhněte oblast lasem a pak přetáhněte výběr
Edit Type Upravit text
Full-resolution preview unavailable Náhled v plném rozlišení není dostupný
No patterns Žádné vzorky
Enable {name} Povolit {name}
Hold Alt while drawing for straight segments Podržením Alt při kreslení vytvoříte rovné úseky

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2124,3 +2124,4 @@
Don't show again Nicht mehr anzeigen
Save failed: {error} Speichern fehlgeschlagen: {error}
Save cancelled Speichern abgebrochen
Full-resolution preview unavailable Vorschau in voller Auflösung nicht verfügbar

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2140,6 +2140,7 @@ cameraRaw Amount Cantidad
Patch: Parche:
Lasso around an area, then drag the selection Rodee un área con el lazo y arrastre la selección
Edit Type Editar texto
Full-resolution preview unavailable Vista previa a resolución completa no disponible
No patterns Sin motivos
Enable {name} Activar {name}
Hold Alt while drawing for straight segments Mantenga Alt al dibujar para segmentos rectos

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2500,6 +2500,7 @@ cameraRaw Blending Fusion
5 x 5 5 × 5
Grid: Grille :
Edit Type Modifier le texte
Full-resolution preview unavailable Aperçu en pleine résolution indisponible
No patterns Aucun motif
Enable {name} Activer {name}
Hold Alt while drawing for straight segments Maintenez Alt en dessinant pour des segments droits

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2487,6 +2487,7 @@ cameraRaw Blending Pembauran
5 x 5 5 × 5
Grid: Kisi-kisi:
Edit Type Edit Teks
Full-resolution preview unavailable Pratinjau resolusi penuh tidak tersedia
No patterns Tidak ada pola
Enable {name} Aktifkan {name}
Hold Alt while drawing for straight segments Tahan Alt saat menggambar untuk segmen lurus

Can't render this file because it contains an unexpected character in line 4 and column 47.

View File

@ -2048,6 +2048,7 @@ cameraRaw Color カラー
Patch: パッチ:
Lasso around an area, then drag the selection 投げ縄で範囲を囲み、選択範囲をドラッグします
Edit Type 文字を編集
Full-resolution preview unavailable フル解像度のプレビューを利用できません
No patterns パターンなし
Enable {name} {name}を有効にする
Hold Alt while drawing for straight segments 描画中に Alt を押すと直線セグメントになります

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2034,6 +2034,7 @@ cameraRaw Color 색상
175% 175%
250% 250%
300% 300%
Full-resolution preview unavailable 전체 해상도 미리보기를 사용할 수 없습니다
# Additional generated filter labels and Photoshop-aligned UI terminology.
@id select.all 모두 선택

Can't render this file because it has a wrong number of fields in line 10.

View File

@ -2523,3 +2523,4 @@ cameraRaw Whites Brancos
Don't show again Não mostrar novamente
Save failed: {error} Falha ao salvar: {error}
Save cancelled Salvamento cancelado
Full-resolution preview unavailable Pré-visualização em resolução total indisponível

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2343,6 +2343,7 @@ cameraRaw Roughness Неровность
Patch: Заплатка:
Lasso around an area, then drag the selection Обведите область лассо, затем перетащите выделение
Edit Type Редактировать текст
Full-resolution preview unavailable Полноразмерный предпросмотр недоступен
No patterns Нет узоров
Enable {name} Включить {name}
Hold Alt while drawing for straight segments Удерживайте Alt при рисовании для прямых отрезков

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -2074,6 +2074,7 @@ cameraRaw Blending 混合
Details 详细信息
Rendering mode 渲染模式
Edit Type 编辑文字
Full-resolution preview unavailable 全分辨率预览不可用
No patterns 无图案
Enable {name} 启用{name}
Hold Alt while drawing for straight segments 绘制中按住 Alt 可逐点连接直线

Can't render this file because it has a wrong number of fields in line 14.

View File

@ -2043,6 +2043,7 @@ cameraRaw Blending 混合
Patch: 修補:
Lasso around an area, then drag the selection 用套索圈出區域,然後拖曳選取範圍
Edit Type 編輯文字
Full-resolution preview unavailable 無法使用完整解析度預覽
No patterns 無圖樣
Enable {name} 啟用{name}
Hold Alt while drawing for straight segments 繪製中按住 Alt 可逐點連接直線

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -27,6 +27,8 @@ pub mod brush_preview;
pub mod brush_resize;
pub mod brush_sections;
pub mod brushes_tab;
mod camera_raw_detail_ui;
mod camera_raw_preview_ui;
mod camera_raw_scope_ui;
pub mod camera_raw_ui;
pub mod canvas;

View File

@ -44,6 +44,22 @@ pub struct CameraRawScopeState {
pub floating_rect: Option<[f32; 4]>,
}
/// Camera Raw navigation, separate from filter settings. Reset for each opened image.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct CameraRawPreviewState {
/// None fits the image; Some is physical display pixels per source pixel.
pub zoom: Option<f32>,
pub center: [f32; 2],
pub hand: bool,
}
impl Default for CameraRawPreviewState {
fn default() -> Self {
Self { zoom: None, center: [0.5, 0.5], hand: false }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Tool {
Move,
@ -782,6 +798,8 @@ pub struct UiState {
pub chrome: crate::chrome_ui::ChromeState,
#[serde(default)]
pub camera_raw_scope: CameraRawScopeState,
#[serde(default)]
pub camera_raw_preview: CameraRawPreviewState,
}
impl Default for UiState {
@ -838,6 +856,7 @@ impl Default for UiState {
gpu_fallback_notice: None,
chrome: Default::default(),
camera_raw_scope: Default::default(),
camera_raw_preview: Default::default(),
}
}
}

View File

@ -0,0 +1,213 @@
//! Camera Raw navigation uses the real modal shell and never changes document history.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use photocraft_ui_egui::{PhotocraftApp, camera_raw_ui, control, theme::ThemeKind};
use serde_json::{Value, json};
fn fixture(ppp: f32) -> Harness<'static, PhotocraftApp> {
let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).with_pixels_per_point(ppp).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, ThemeKind::Pro);
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width":1200,"height":900})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("edit.fill", json!({"color":"#808080"})).unwrap();
camera_raw_ui::open(&mut app, &cc.egui_ctx).unwrap();
app
});
h.run_steps(4);
h
}
fn inspect(h: &Harness<'_, PhotocraftApp>) -> Value {
control::inspect(h.state(), &h.ctx)["cameraRaw"].clone()
}
fn rectangle(h: &Harness<'_, PhotocraftApp>, key: &str) -> Rect {
let v = inspect(h);
let a = v[key].as_array().unwrap();
Rect::from_min_max(pos2(a[0].as_f64().unwrap() as f32, a[1].as_f64().unwrap() as f32), pos2(a[2].as_f64().unwrap() as f32, a[3].as_f64().unwrap() as f32))
}
fn menu(h: &mut Harness<'_, PhotocraftApp>, ui: Value) -> Result<Value, String> {
let ctx = h.ctx.clone();
camera_raw_ui::menu(h.state_mut(), &ctx, "filter.cameraRaw", &json!({"ui":ui})).unwrap()
}
fn press(h: &mut Harness<'_, PhotocraftApp>, at: Pos2, down: bool, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.event(Event::PointerMoved(at));
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: down, modifiers });
h.run_steps(2);
}
fn drag(h: &mut Harness<'_, PhotocraftApp>, start: Pos2, end: Pos2, modifiers: Modifiers) {
press(h, start, true, modifiers);
for n in 1..=4 {
h.event(Event::PointerMoved(start + (end - start) * n as f32 / 4.0));
h.run_steps(2);
}
press(h, end, false, modifiers);
}
fn key(h: &mut Harness<'_, PhotocraftApp>, key: Key, modifiers: Modifiers, down: bool) {
h.event(Event::ModifiersChanged(modifiers));
h.event(Event::Key { key, physical_key: None, pressed: down, repeat: false, modifiers });
h.run_steps(2);
}
fn zoom(h: &Harness<'_, PhotocraftApp>) -> f64 {
inspect(h)["zoom"].as_f64().unwrap()
}
#[test]
fn click_toggles_fit_and_actual_pixels_at_both_display_scales_without_editing() {
for ppp in [1.0, 2.0] {
let mut h = fixture(ppp);
let initial = inspect(&h);
let history = h.state().session.active().unwrap().history.past_len();
let revision = initial["previewRevision"].clone();
let at = rectangle(&h, "viewportRect").center();
press(&mut h, at, true, Modifiers::NONE);
press(&mut h, at, false, Modifiers::NONE);
assert_eq!(zoom(&h), 1.0);
assert!((rectangle(&h, "previewRect").width() * ppp - 1200.0).abs() < 0.01, "100% maps one source pixel to one display pixel");
assert_eq!(inspect(&h)["previewApproximate"], false);
// Separate clicks so egui does not treat them as a double click.
h.run_steps(30);
press(&mut h, at, true, Modifiers::NONE);
press(&mut h, at, false, Modifiers::NONE);
assert_eq!(inspect(&h)["view"]["zoom"], Value::Null);
assert_eq!(inspect(&h)["previewRevision"], revision);
assert_eq!(h.state().session.active().unwrap().history.past_len(), history);
}
}
#[test]
fn scrub_zoom_and_alt_wheel_preserve_the_detail_under_the_initial_pointer() {
let mut h = fixture(1.0);
menu(&mut h, json!({"view":{"zoom":2.0}})).unwrap();
h.run_steps(2);
let at = rectangle(&h, "viewportRect").center() + vec2(35.0, 20.0);
let image = rectangle(&h, "previewRect");
let point = (at - image.min) / image.size();
drag(&mut h, at, at + vec2(60.0, 0.0), Modifiers::NONE);
assert!(zoom(&h) > 2.5, "rightward scrub increases zoom");
let image = rectangle(&h, "previewRect");
assert!(((at - image.min) / image.size() - point).length() < 0.002);
let previous = zoom(&h);
drag(&mut h, at, at - vec2(60.0, 0.0), Modifiers::NONE);
assert!(zoom(&h) < previous, "leftward scrub decreases zoom");
let old = zoom(&h);
h.event(Event::PointerMoved(at));
h.event(Event::ModifiersChanged(Modifiers::ALT));
h.event(Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, 100.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::ALT });
// Release Alt while the wheel notch is still being smoothed: no residual pan.
h.event(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(10);
assert!(zoom(&h) > old);
let image = rectangle(&h, "previewRect");
assert!(((at - image.min) / image.size() - point).length() < 0.002);
}
#[test]
fn space_pans_while_sampling_without_moving_probes_or_editing_the_filter() {
let mut h = fixture(1.0);
menu(&mut h, json!({"view":{"zoom":2.0},"scope":{"samplerTool":true,"samplers":[[0.5,0.5]]}})).unwrap();
h.run_steps(2);
let initial = inspect(&h);
let at = rectangle(&h, "viewportRect").center();
key(&mut h, Key::Space, Modifiers::NONE, true);
drag(&mut h, at, at + vec2(80.0, 40.0), Modifiers::NONE);
key(&mut h, Key::Space, Modifiers::NONE, false);
assert_ne!(inspect(&h)["view"]["center"], initial["view"]["center"]);
assert_eq!(inspect(&h)["scope"]["samplers"], initial["scope"]["samplers"]);
assert_eq!(inspect(&h)["params"], initial["params"]);
assert_eq!(inspect(&h)["previewRevision"], initial["previewRevision"]);
h.event(Event::PointerMoved(at));
h.run_steps(2);
let image = rectangle(&h, "previewRect");
let point = (at - image.min) / image.size();
let sample = inspect(&h)["pointerReadout"]["position"].clone();
assert!((sample[0].as_f64().unwrap() - point.x as f64).abs() < 0.001);
assert!((sample[1].as_f64().unwrap() - point.y as f64).abs() < 0.001);
}
#[test]
fn command_shortcuts_and_box_zoom_use_the_same_path_for_control_and_command_keys() {
for modifiers in [Modifiers { ctrl: true, command: true, ..Modifiers::NONE }, Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }] {
let mut h = fixture(1.0);
let at = rectangle(&h, "viewportRect").center();
drag(&mut h, at - vec2(90.0, 60.0), at + vec2(90.0, 60.0), modifiers);
assert!(zoom(&h) > 1.5, "command-drag fits the selected region");
let old = zoom(&h);
key(&mut h, Key::Minus, modifiers, true);
key(&mut h, Key::Minus, modifiers, false);
assert!(zoom(&h) < old);
key(&mut h, Key::Num0, modifiers, true);
key(&mut h, Key::Num0, modifiers, false);
assert_eq!(inspect(&h)["view"]["zoom"], Value::Null);
key(&mut h, Key::Num0, modifiers | Modifiers::ALT, true);
key(&mut h, Key::Num0, modifiers | Modifiers::ALT, false);
assert_eq!(zoom(&h), 1.0);
menu(&mut h, json!({"view":{"zoom":2.0},"scope":{"samplerTool":true}})).unwrap();
key(&mut h, Key::H, Modifiers::NONE, true);
key(&mut h, Key::H, Modifiers::NONE, false);
assert_eq!(inspect(&h)["scope"]["samplerTool"], false);
assert_eq!(inspect(&h)["view"]["hand"], true);
drag(&mut h, at, at + vec2(60.0, 0.0), modifiers);
assert!(zoom(&h) > 2.5, "command temporarily zooms while Hand is selected");
key(&mut h, Key::Z, Modifiers::NONE, true);
key(&mut h, Key::Z, Modifiers::NONE, false);
press(&mut h, at, true, modifiers | Modifiers::SHIFT);
press(&mut h, at, false, modifiers | Modifiers::SHIFT);
assert_eq!(zoom(&h), 1.0, "command-shift-click returns to actual pixels");
}
}
#[test]
fn invalid_view_requests_are_atomic_and_reopening_starts_in_fit_view() {
let mut h = fixture(1.0);
let before = inspect(&h);
for view in [
json!(null),
json!({"zoom":0}),
json!({"zoom":100}),
json!({"zoom":"100%"}),
json!({"center":[-1,0]}),
json!({"center":[0]}),
json!({"hand":1}),
json!({"unknown":true}),
] {
assert!(menu(&mut h, json!({"view":view,"set":{"exposure":1.0},"before":true})).is_err());
assert_eq!(inspect(&h), before);
}
menu(&mut h, json!({"view":{"zoom":2.0,"hand":true}})).unwrap();
menu(&mut h, json!({"cancel":true})).unwrap();
menu(&mut h, json!({})).unwrap();
h.run_steps(2);
assert_eq!(inspect(&h)["view"], json!({"zoom":null,"center":[0.5,0.5],"hand":false}));
h.get_by_label(photocraft_ui_egui::i18n::tr(photocraft_ui_egui::i18n::current(), "100%")).click();
h.run_steps(2);
assert_eq!(zoom(&h), 1.0);
}
#[test]
fn asynchronous_refinement_completes_for_the_latest_edit_and_before_keeps_navigation() {
let mut h = fixture(1.0);
menu(&mut h, json!({"view":{"zoom":1.0},"set":{"exposure":0.5},"scope":{"samplers":[[0.5,0.5]]}})).unwrap();
h.run_steps(2);
// Changing parameters while refinement may still be running must never display an old edit.
menu(&mut h, json!({"set":{"exposure":1.0}})).unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while inspect(&h)["previewApproximate"] != false && std::time::Instant::now() < deadline {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert_eq!(inspect(&h)["detailError"], Value::Null);
assert_eq!(inspect(&h)["detailPending"], false);
assert_eq!(inspect(&h)["previewApproximate"], false);
let developed = inspect(&h)["samplerReadouts"][0]["values"][0].as_f64().unwrap();
let view = inspect(&h)["view"].clone();
menu(&mut h, json!({"before":true})).unwrap();
h.run_steps(2);
assert_eq!(inspect(&h)["view"], view);
assert_eq!(inspect(&h)["previewApproximate"], false);
let original = inspect(&h)["samplerReadouts"][0]["values"][0].as_f64().unwrap();
assert!(developed > original + 20.0);
menu(&mut h, json!({"before":false})).unwrap();
h.run_steps(2);
assert_eq!(inspect(&h)["samplerReadouts"][0]["values"][0].as_f64().unwrap(), developed);
}

46
docs/camera-raw-zoom.md Normal file
View File

@ -0,0 +1,46 @@
# Camera Raw preview navigation
The Camera Raw dialog's preview has Zoom, Hand, Fit in View, 100%, and zoom preset
controls at the bottom left.
Choose **Fit in View**, or press **Ctrl+0** on Windows/Linux (**Command+0** on macOS),
to fit the whole image inside the preview.
At **100%**, one source pixel maps to one physical display pixel, including HiDPI.
The native preview refines the processed source in the background so texture,
sharpening, and grain can be inspected beyond the reduced proxy's resolution.
## Controls
| Action | Windows / Linux | macOS |
|---|---|---|
| Select Zoom | Z | Z |
| Scrub zoom in / out | Left-drag right / left with Zoom | Same |
| Toggle Fit / 100% | Click with Zoom | Same |
| Zoom a selected region | Ctrl+drag | Command+drag |
| Select Hand | H | H |
| Temporary pan | Space+drag, or middle-button drag | Same |
| Pointer-anchored zoom | Alt+wheel, or pinch | Option+wheel, or pinch |
| Zoom in / out | Ctrl+plus / minus | Command+plus / minus |
| Fit in View | Ctrl+0 | Command+0 |
| Actual size | Ctrl+Alt+0 | Command+Option+0 |
| Zoom presets | Bottom-left dropdown or right-click menu | Same |
Zoom and pan are view state: they do not change filter parameters or document
history. Before/After keeps the same position; colour probes and clipping overlays
follow the image coordinates. Cancel discards the filter; OK applies it through
the existing Camera Raw command.
## Preview limits
The fast proxy remains available while native refinement is pending. Processed
full-resolution refinement is limited to 64 megapixels because the existing
engine allocates full-image temporary buffers. Larger sources and failed
refinement retain the proxy with a notification. Only the visible crop is uploaded
to the GPU (at most 16,777,216 pixels and the device texture-size limit).
The initial preview domain is bounded to 512 megapixels, including sparse content
outside the canvas. Histogram and vectorscope statistics remain proxy-based.
WebAssembly retains the processed proxy until a browser worker path is available.
Gesture references: [Adobe Camera Raw introduction](https://helpx.adobe.com/camera-raw/desktop/get-started/overview-and-setup/introduction-camera-raw.html)
and [Julieanne Kost's Camera Raw shortcuts](https://jkost.com/blog/2022/10/225-shortcuts-tips-and-tricks-for-adobe-camera-raw.html).

View File

@ -306,7 +306,7 @@ a driver process crash is detected by the startup marker on the next launch.
RGB/Grayscale layer. `params: {"smartFilter": {"layer": id, "index": i}}` opens it on an existing
Camera Raw smart filter instead (as double-clicking the filter in the Layers panel does): the
stored settings over the pixels below that filter, previewed through the filter mask; commit
then runs `layer.smartFilter.setParams` with every setting. `params.ui` accepts `set` (filter settings), `before`, `scope`, `commit` and
then runs `layer.smartFilter.setParams` with every setting. `params.ui` accepts `set` (filter settings), `before`, `scope`, `view`, `commit` and
`cancel`. All parts of one request are validated before any is applied; a rejected request
leaves the settings, view state, preferences and document unchanged (and closes a dialog it
opened). Unknown `ui` or settings properties, non-boolean `before` / `commit` / `cancel`, and
@ -339,6 +339,38 @@ The response and `ui.inspect.cameraRaw` contain:
- `hoverSample`: `position`, `rgb` (histogram domain) and `hueSaturation` (turns, 0–1; only
while the vectorscope is shown), or null.
- `hoveredZone`, `previewRect`, `scopeRect`, `vectorscopeRect`.
- `view` (`zoom: null` for fit, or a numeric factor where 1 = 100%, `center: [u,v]`,
`hand`), `zoom` (effective factor, null before layout), `sourceSize`, `viewportRect`.
At 100%, one source pixel maps to one physical display pixel, including on HiDPI displays.
- `previewApproximate`, `detailPending`, `detailError`: whether the visible image still uses
the proxy, whether full-resolution refinement is running, and any refinement error.
`params.ui.view` changes navigation only: `zoom: "fit" | 0.0001..=16`, `center: [u,v]`
(finite coordinates in 0..=1, clamped to keep the image within view), `hand: bool`. Fit recentres
and follows window resizing; navigation resets when opening a different Camera Raw dialog.
Invalid mixed requests are atomic, just like scope and filter settings. View requests do not
re-develop the proxy or change filter parameters/history.
With the Zoom tool (Z), click toggles Fit / 100%; click-drag right/left scrubs in/out.
Ctrl-drag (Command on macOS) fits a rectangular region. H selects Hand; Space temporarily pans,
including while placing colour samplers. Alt/Option + wheel zooms at the pointer; an unmodified
wheel pans. Pinch zooms. Ctrl/Command +/- zoom, 0 fits, and Alt/Option + Ctrl/Command + 0 shows
100%; Ctrl/Command+Shift-click also selects 100%. Right-click opens Fit / 100% / preset
percentages. Tool double-clicks fit the image.
Text fields retain their keys and wheel gestures over the settings panel still scroll it.
The proxy remains the fast path while changing settings. Where more source pixels are needed,
Camera Raw lazily refines with the **same engine Camera Raw pipeline** at pixelScale=1 and the
same selection/filter-mask coverage. Native refinement runs off the UI thread (one revision at
a time); stale results are discarded. Only a bounded visible crop is uploaded to the GPU, and
pan/zoom reuse developed pixels. Before and neutral settings read the original pixels directly.
Absurd proxy domains (over 512 MP, including distant sparse off-canvas pixels) are rejected
before reading pixels. Full-resolution refinement is capped at 64 MP to bound the existing full-image engine pipeline;
above that it explicitly reports unavailability and keeps the proxy. Oversized viewport crops
also retain the proxy. Without a browser worker, wasm explicitly retains the filtered proxy
rather than block its UI; Before and neutral settings still crop the original source pixels.
The histogram and vectorscope continue to analyse the bounded proxy; readouts and clipping
warnings use the full-resolution pixels once refinement is visible.
`params.ui.scope` changes presentation only: `shadows` / `highlights` (clipping warnings, U / O),
`lab`, `samplerTool` (S), `vectorscope`, `selectedRegion` (requires a document selection),