diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 00fdc389..951f827d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -776,15 +776,6 @@ fn toggle(slot: &mut bool, p: &Value) -> Value { json!(*slot) } -/// The next View ▸ Zoom In / Zoom Out magnification (points per pixel) after `cur`. -fn zoom_step(cur: f32, zoom_in: bool) -> f32 { - const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0]; - if zoom_in { - STEPS.iter().copied().find(|s| *s > cur + 1e-3).unwrap_or(32.0) - } else { - STEPS.iter().rev().copied().find(|s| *s < cur - 1e-3).unwrap_or(0.015) - } -} /// Perform a frontend command (from `Event::Frontend`). pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Value) -> Result { @@ -914,13 +905,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu // the Composition viewer. "view.zoomIn" | "view.zoomOut" if app.ui.focused == PanelKind::Layer => { let cur = app.ui.layer_zoom.unwrap_or_else(|| crate::panels::layer_panel::last_fit(ctx)); - let next = zoom_step(cur, id == "view.zoomIn"); + // The Layer panel zooms in points (pixels-per-point 1). + let next = zoom_step(cur, 1.0, id == "view.zoomIn"); app.ui.layer_zoom = Some(next); json!({"zoom": next, "panel": "layer"}) } "view.zoomIn" | "view.zoomOut" => { + let ppp = ctx.pixels_per_point(); let cur = v.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx)); - let next = zoom_step(cur, id == "view.zoomIn"); + let next = zoom_step(cur, ppp, id == "view.zoomIn"); v.zoom = Some(next); json!({"zoom": next}) } @@ -2003,10 +1996,38 @@ fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> egui::R ui.add_enabled(enabled, b) } +/// View ▸ Zoom In / Zoom Out: the next standard magnification (as a share of screen pixels, so +/// 100 % is one comp pixel per screen pixel at any display scaling) above or below `cur`, which is +/// in points per comp pixel like the result (#882). +fn zoom_step(cur: f32, ppp: f32, zoom_in: bool) -> f32 { + const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0]; + let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 }; + let shown = cur * ppp; + let next = if zoom_in { + STEPS.iter().copied().find(|s| *s > shown + 1e-3).unwrap_or(32.0) + } else { + STEPS.iter().rev().copied().find(|s| *s < shown - 1e-3).unwrap_or(0.015) + }; + next / ppp +} + #[cfg(test)] mod tests { use super::*; + /// #882: at 125 % / 150 % display scaling Zoom In reaches 100 % (one comp pixel per screen + /// pixel), not 100 % in points (125 % / 150 % on screen). + #[test] + fn zoom_steps_land_on_whole_screen_magnifications() { + for ppp in [1.0f32, 1.25, 1.5, 2.0] { + let at_100 = zoom_step(0.7 / ppp, ppp, true); + assert!((at_100 * ppp - 1.0).abs() < 1e-6, "ppp {ppp}: {}", at_100 * ppp); + assert!((zoom_step(at_100, ppp, true) * ppp - 1.5).abs() < 1e-6); + assert!((zoom_step(at_100, ppp, false) * ppp - 0.66).abs() < 1e-6); + } + assert!(zoom_step(1.0, 0.0, true).is_finite()); + } + #[test] fn shortcut_passes_share_frame_start_modifiers() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index a41d7883..9ab13879 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -798,7 +798,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-fit"), fit)); let zoom = app.ui.viewer.zoom.unwrap_or(fit); let center = area.center() + vec2(app.ui.viewer.pan[0], app.ui.viewer.pan[1]); - let origin = center - vec2(cw * zoom / 2.0, ch * zoom / 2.0); + // On the physical pixel grid, so at 100 % every comp pixel covers exactly one screen pixel + // instead of being blended with its neighbour (#882). + let ppp = ctx.pixels_per_point(); + let origin = snap_to_pixels(center - vec2(cw * zoom / 2.0, ch * zoom / 2.0), ppp); let map = ViewerMap { origin, zoom, comp: [cw, ch], area }; ctx.data_mut(|d| d.insert_temp(map_id(), map)); let comp_rect = Rect::from_min_size(origin, vec2(cw * zoom, ch * zoom)); @@ -808,7 +811,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.ui.viewer.extended = extended_region(app, &comp, cid, map); // Frame. - let ppp = ctx.pixels_per_point(); let scale = app.viewer_scale(zoom, ppp); if !app.playback.playing { // Queue the frame on screen before any prefetch. @@ -2484,21 +2486,32 @@ pub(crate) fn styled_line(painter: &egui::Painter, pts: [Pos2; 2], stroke: Strok } } -/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest. +/// `p` (points) moved to the nearest physical pixel corner at `ppp` pixels per point. +pub(crate) fn snap_to_pixels(p: Pos2, ppp: f32) -> Pos2 { + if !(ppp.is_finite() && ppp > 0.0 && p.x.is_finite() && p.y.is_finite()) { + return p; + } + pos2((p.x * ppp).round() / ppp, (p.y * ppp).round() / ppp) +} + +/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest. As in +/// After Effects, a magnified frame (above 100 %, or a lower Resolution) always shows its pixels +/// as blocks, never smoothed, in either mode (#882). pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions { - if smooth { egui::TextureOptions::LINEAR } else { egui::TextureOptions::NEAREST } + if smooth { egui::TextureOptions { magnification: egui::TextureFilter::Nearest, ..egui::TextureOptions::LINEAR } } else { egui::TextureOptions::NEAREST } } /// The sampler GPU frames (mipmapped display textures) are drawn with: More Accurate filters /// bilinearly within and between mip levels, so a frame shown below 100 % is averaged instead of -/// skipping pixels (#417); Faster takes the nearest pixel of the full-size frame. +/// skipping pixels (#417); Faster takes the nearest pixel of the full-size frame. Magnified, both +/// show the nearest pixel (#882). pub(crate) fn zoom_sampler(smooth: bool) -> eframe::wgpu::SamplerDescriptor<'static> { use eframe::wgpu::{FilterMode, MipmapFilterMode, SamplerDescriptor}; let label = Some("viewer frame"); if smooth { SamplerDescriptor { label, - mag_filter: FilterMode::Linear, + mag_filter: FilterMode::Nearest, min_filter: FilterMode::Linear, mipmap_filter: MipmapFilterMode::Linear, ..Default::default() @@ -2596,6 +2609,35 @@ mod tests { use super::*; use effectcraft_engine::geom::{Mat4, vec3}; + /// #882: the frame's corner sits on a physical pixel at any display scaling, so 100 % maps + /// comp pixels 1:1 onto screen pixels. + #[test] + fn frame_origin_snaps_to_physical_pixels() { + for ppp in [1.0f32, 1.25, 1.5, 1.75, 2.0] { + for p in [pos2(-201.12, 72.4), pos2(689.8, 239.75), pos2(0.3, -0.37)] { + let s = snap_to_pixels(p, ppp); + for v in [s.x * ppp, s.y * ppp] { + assert!((v - v.round()).abs() < 1e-3, "ppp {ppp}: {p:?} → {s:?}"); + } + assert!((s - p).length() <= 0.71 / ppp, "moved by at most half a pixel: {p:?} → {s:?}"); + } + } + assert_eq!(snap_to_pixels(pos2(1.3, 2.0), 0.0), pos2(1.3, 2.0)); + } + + /// #882: a magnified frame shows its pixels as blocks in both Zoom Quality modes; More + /// Accurate only smooths when the frame is shown smaller. + #[test] + fn magnified_frames_are_never_smoothed() { + use eframe::wgpu::FilterMode; + for smooth in [true, false] { + assert_eq!(zoom_texture_options(smooth).magnification, egui::TextureFilter::Nearest); + assert_eq!(zoom_sampler(smooth).mag_filter, FilterMode::Nearest); + } + assert_eq!(zoom_texture_options(true).minification, egui::TextureFilter::Linear); + assert_eq!(zoom_sampler(true).min_filter, FilterMode::Linear); + } + /// A perspective-like homography: w = 100 + y (the plane crosses the camera at y = −100). fn tilted() -> Mat3 { Mat3([[100.0, 0.0, 0.0], [0.0, 100.0, 0.0], [0.0, 1.0, 100.0]]) diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 680d2199..2f7862e2 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -403,7 +403,8 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: _ => { let img = crate::frames::to_color_image(&s.image); let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure, dc) }; - let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, egui::TextureOptions::LINEAR); + let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); + let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, opts); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 0768b081..c5289ded 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -2024,3 +2024,25 @@ fn vertex_tools_edit_motion_path_keyframes() { click(&mut h, quarter); assert_eq!(keys(&h).len(), 3); } + +/// #882: at 100 % (and 200 %) on a scaled display the frame's corner sits on a physical pixel, so +/// every comp pixel covers whole screen pixels instead of being blended with its neighbour. +#[test] +fn frame_sits_on_the_physical_pixel_grid() { + for ppp in [1.0f32, 1.25, 1.5] { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Grid", "width": 301, "height": 201, "duration": 1})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(ppp).build_eframe(move |_| EffectcraftApp::new(s)); + h.run_steps(3); + for (mag, pan) in [(1.0f32, [0.3f32, 0.37]), (2.0, [-0.45, 0.2])] { + h.state_mut().ui.viewer.zoom = Some(mag / ppp); + h.state_mut().ui.viewer.pan = pan; + h.run_steps(2); + let c = rect(&h, "viewer.comp"); + for v in [c.min.x * ppp, c.min.y * ppp] { + assert!((v - v.round()).abs() < 1e-3, "ppp {ppp}, {mag}×: corner at {v} physical px"); + } + assert!((c.width() * ppp - 301.0 * mag).abs() < 1e-2, "ppp {ppp}, {mag}×: {} physical px wide", c.width() * ppp); + } + } +} diff --git a/docs/preferences.md b/docs/preferences.md index 1e04c437..3b92782c 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -96,7 +96,9 @@ Commands (CLI, MCP, control channel): how much of a position motion path the viewer draws (all, none, N seconds or N keyframes around the current time) - `previews.showInternalWireframes`: outlines of the layers inside a selected collapsed precomp -- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = bilinear +- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = averaged + (bilinear, mipmapped) when the frame is shown below 100 %; magnified frames show their pixels as + blocks in both modes, and the frame sits on the physical pixel grid, so 100 % is exact - `previews.displayProfile`: the monitor's colour space for View ▸ Use Display Color Management - `appearance.useLabelColorForTabs`: Composition / Timeline tabs show the comp's label colour, Effect Controls / Properties the layer's