Canvas: zoom is physical at any display scale (#1978)

View zoom now means device pixels per document pixel, so 100% shows one image pixel per physical
display pixel on a scaled (125%, 200%) display, as in Photoshop. Fit, Actual Pixels, pan/zoom anchoring,
scrollbars, the navigator, the pixel grid and every hit tolerance go through canvas_ppp / point_zoom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Max Murphy
2026-10-09 16:10:49 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 32c918aaee
commit 27263bb16d
34 changed files with 257 additions and 114 deletions
+3
View File
@@ -57,3 +57,6 @@ sync_github_name = true
[people.Hathor875]
real_name = "Krzysztof Cieślik"
sync_github_name = true
[people.maxm11]
sync_github_name = true
+1 -1
View File
@@ -623,7 +623,7 @@ mod tests {
let idx = app.session.active_index().unwrap();
assert!(app.ui.views[idx].fit_pending);
let doc = &app.session.active().unwrap().doc;
crate::canvas::fit_view(&mut app.ui.views[idx], doc, vec2(100.0, 100.0));
crate::canvas::fit_view(&mut app.ui.views[idx], doc, vec2(100.0, 100.0), 1.0);
assert_eq!(app.ui.views[idx].zoom, 0.75);
assert_eq!(app.ui.views[idx].center, [40.0, 30.0]);
assert!(!app.ui.views[idx].fit_pending);
+2 -2
View File
@@ -203,7 +203,7 @@ fn export_log(app: &mut PhotocraftApp, rows: Option<Vec<u64>>) -> Result<Value,
// ------------------------------------------------------------------ tools
fn tolerance(app: &PhotocraftApp) -> f64 {
7.0 / f64::from(app.current_zoom().max(0.01))
7.0 / f64::from(app.point_zoom().max(0.01))
}
fn dist(a: [f64; 2], b: [f64; 2]) -> f64 {
@@ -293,7 +293,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
}
}
(Tool::Note, ToolEvent::Down { x, y, .. }) => {
let z = f64::from(app.current_zoom().max(0.01));
let z = f64::from(app.point_zoom().max(0.01));
let hit =
doc.notes.iter().rposition(|n| x >= n.position[0] && x <= n.position[0] + 16.0 / z && y >= n.position[1] && y <= n.position[1] + 20.0 / z);
match hit {
+4 -2
View File
@@ -63,9 +63,11 @@ pub fn fit_artboard(app: &mut PhotocraftApp) -> Result<Value, String> {
let size = [st.doc.size.width, st.doc.size.height];
let area = app.last_canvas_rect.size();
let area = if area.x > 50.0 { area } else { egui::vec2(1200.0, 800.0) };
let ppp = app.canvas_ppp();
let v = &mut app.ui.views[i];
// Leave room for the name above the board.
v.zoom = ((area.x - 60.0) / b.width().max(1) as f32).min((area.y - 80.0) / b.height().max(1) as f32);
// Leave room for the name above the board. The area is in egui points; the stored zoom is
// device pixels per document pixel.
v.zoom = ((area.x - 60.0) / b.width().max(1) as f32).min((area.y - 80.0) / b.height().max(1) as f32) * ppp;
v.zoom = crate::zoom_levels::clamp(v.zoom, size);
// Widen before adding: a board near ±2^30, or one whose far edge saturated at i32::MAX,
// overflows an i32 sum (#981).
+1 -1
View File
@@ -108,7 +108,7 @@ pub fn release_stale(app: &mut PhotocraftApp, secondary_down: bool) {
fn update(app: &mut PhotocraftApp, r: Resize, x: f64, y: f64) {
// Right is bigger on screen, also in a flipped view (View › Flip Horizontal).
let dx = (x - r.anchor[0]) * if app.ui.view.flip_horizontal { -1.0 } else { 1.0 };
let (size, hardness) = resized(r.start, dx, y - r.anchor[1], app.current_zoom());
let (size, hardness) = resized(r.start, dx, y - r.anchor[1], app.point_zoom());
let b = &app.session.tools.brush;
if b.size == size && b.hardness == hardness {
return;
+147 -43
View File
@@ -325,7 +325,7 @@ fn live_stroke(app: &PhotocraftApp, idx: usize) -> Option<&LiveStroke> {
/// `paint.stroke` params for a Brush/Eraser drag (shared by the live preview and the commit). The
/// stroke smoothing is the session brush's (the options bar's Smoothing %).
fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec<f64>]) -> serde_json::Value {
let mut p = json!({ "points": points, "erase": erase, "zoom": app.current_zoom(), "target": paint_target(app) });
let mut p = json!({ "points": points, "erase": erase, "zoom": app.point_zoom(), "target": paint_target(app) });
if tool == Tool::Pencil {
p["autoErase"] = json!(app.ui.tool_options.pencil_auto_erase);
}
@@ -573,7 +573,7 @@ impl ViewXform {
let v = app.ui.views.get(app.session.active_index()?)?;
Some(Self {
rect: crate::rulers::content_rect(app, app.last_canvas_rect),
zoom: v.zoom,
zoom: v.zoom / app.canvas_ppp(),
center: v.center,
flip: app.ui.view.flip_horizontal,
rotation: v.rotation,
@@ -627,9 +627,12 @@ impl ViewXform {
}
}
pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) {
/// Fit the document into `area` (egui points) for a display with `ppp` physical pixels per
/// point; `View::zoom` is stored in physical pixels per document pixel.
pub fn fit_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32) {
let (w, h) = (doc.size.width as f32, doc.size.height as f32);
let zoom = crate::zoom_levels::clamp(((area.x - 40.0) / w).min((area.y - 40.0) / h).min(1.0), [doc.size.width, doc.size.height]);
let points = ((area.x - 40.0) / w).min((area.y - 40.0) / h).min(1.0);
let zoom = crate::zoom_levels::clamp(points * ppp, [doc.size.width, doc.size.height]);
view.zoom = zoom;
view.center = [w / 2.0, h / 2.0];
view.fit_pending = false;
@@ -638,9 +641,10 @@ pub fn fit_view(view: &mut View, doc: &Document, area: Vec2) {
/// Centre the document and zoom until it fills the canvas. One axis can extend beyond the
/// viewport, matching the Hand tool's Fill Screen action.
pub fn fill_view(view: &mut View, doc: &Document, area: Vec2) {
pub fn fill_view(view: &mut View, doc: &Document, area: Vec2, ppp: f32) {
let (w, h) = (doc.size.width as f32, doc.size.height as f32);
let zoom = crate::zoom_levels::clamp((area.x / w).max(area.y / h), [doc.size.width, doc.size.height]);
let points = (area.x / w).max(area.y / h);
let zoom = crate::zoom_levels::clamp(points * ppp, [doc.size.width, doc.size.height]);
view.zoom = zoom;
view.center = [w / 2.0, h / 2.0];
view.fit_pending = false;
@@ -2104,6 +2108,14 @@ fn hdr_preview(app: &PhotocraftApp, doc: &photocraft_doc::Document) -> Option<[f
/// Draw one canvas view and handle its input. `primary` = main window (tools active).
pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Rect, mut view: View, primary: bool) -> View {
let ctx = ui.ctx().clone();
// `View::zoom` is device pixels per document pixel; this canvas's geometry is in its own
// viewport's egui points. A document window can sit on another display with a different
// scale, so only the primary canvas records `app.ppp` for the frame's shared helpers
// (`point_zoom`, the navigator, control-channel coordinate mapping).
let ppp = ctx.pixels_per_point();
if primary {
app.ppp = ppp;
}
// The display this window is on: its monitor profile (#569). A document window on a display
// the last reading didn't know asks for a new one.
let output = crate::monitor_status::view_display(app, &ctx);
@@ -2122,17 +2134,19 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
view.doc_size = size;
}
if view.fit_pending && rect.width() > 50.0 {
fit_view(&mut view, &doc, rect.size());
fit_view(&mut view, &doc, rect.size(), ppp);
}
if view.fill_pending && rect.width() > 50.0 {
fill_view(&mut view, &doc, rect.size());
fill_view(&mut view, &doc, rect.size(), ppp);
}
// Preferences › Tools › Overscroll off: clamp before anything is drawn (scrollbars.rs).
if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size()) {
if !app.session.prefs().tools.overscroll && crate::scrollbars::clamp_view(&mut view, rect.size(), ppp) {
ctx.request_repaint();
}
let flip = app.ui.view.flip_horizontal;
let xf = ViewXform { rect, zoom: view.zoom, center: view.center, flip, rotation: view.rotation };
// Canvas geometry (ViewXform, ViewParams, painter rects) is in egui points.
let point_zoom = view.zoom / ppp;
let xf = ViewXform { rect, zoom: point_zoom, center: view.center, flip, rotation: view.rotation };
let pixel_grid = app.ui.view.shows(app.ui.view.show.pixel_grid);
let response = ui.allocate_rect(rect, Sense::click_and_drag());
let painter = ui.painter_at(rect);
@@ -2159,16 +2173,15 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let gpu_ok = crate::gpu_canvas::gpu_view_ok(flip, view.rotation);
if app.gpu.is_some()
&& gpu_ok
&& let Some((k, key, preview_size)) = ensure_adjust_proxy(app, idx, view.zoom * ctx.pixels_per_point())
.or_else(|| ensure_filter_preview(app, idx, &ctx))
.or_else(|| ensure_proxy_preview(app, idx))
&& let Some((k, key, preview_size)) =
ensure_adjust_proxy(app, idx, view.zoom).or_else(|| ensure_filter_preview(app, idx, &ctx)).or_else(|| ensure_proxy_preview(app, idx))
{
on_gpu = true;
let original_size = [doc.size.width.div_ceil(k), doc.size.height.div_ceil(k)];
let params = crate::gpu_canvas::ViewParams {
doc: key,
doc_size: preview_size,
zoom: view.zoom * k as f32,
zoom: view.zoom * k as f32 / ppp,
// A size-changing preview grows around the old image center; keep the view's pan.
center: [
view.center[0] / k as f32 + (preview_size[0] as f32 - original_size[0] as f32) / 2.0,
@@ -2193,7 +2206,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let params = crate::gpu_canvas::ViewParams {
doc: doc.id.0,
doc_size: [doc.size.width, doc.size.height],
zoom: view.zoom,
zoom: point_zoom,
center: view.center,
rotation: if view.rotation.is_finite() { view.rotation.to_radians() } else { 0.0 },
shadow: {
@@ -2304,7 +2317,9 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// Selection outline: true boundary, animated marching ants (cached per revision).
if let Some(sel) = doc.selection.as_ref().filter(|_| app.ui.view.shows(app.ui.view.show.selection_edges) && !polygon_replaces_selection(app)) {
// Trace at display resolution over the visible part only; key by the mask's tile identity
// (not the document revision) so unrelated edits don't re-trace it.
// (not the document revision) so unrelated edits don't re-trace it. The step is in
// device pixels per document pixel (`View::zoom`, not the point zoom), so a 1 px
// selection still traces at 100% on a scaled display.
let step = (1.0 / view.zoom.max(1e-3)).log2().floor().exp2().clamp(1.0, 64.0) as u32;
let corners = [rect.min, pos2(rect.max.x, rect.min.y), rect.max, pos2(rect.min.x, rect.max.y)];
let docs = [xf.to_doc(corners[0]), xf.to_doc(corners[1]), xf.to_doc(corners[2]), xf.to_doc(corners[3])];
@@ -2373,11 +2388,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
// zooms nor slides the image towards the pointer.
let nz = crate::zoom_levels::clamp(view.zoom * f, view.doc_size);
if nz != view.zoom {
zoom_about(&mut view, &xf, p, nz, false);
zoom_about(&mut view, &xf, p, nz, false, ppp);
}
}
(Some(crate::wheel_nav::Wheel::Pan(scroll)), _) => {
let d = xf.unmap_vec(scroll) / view.zoom;
let d = xf.unmap_vec(scroll) / (view.zoom / ppp);
view.center[0] -= d.x;
view.center[1] -= d.y;
}
@@ -2436,7 +2451,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
primary && app.ui.dialogs.last().is_some_and(|d| crate::color_range_ui::owns(&d.fields) && crate::color_range_ui::controls(&d.fields).sampling);
let hand = app.ui.tool == Tool::Hand && !picking && !range_picking;
if let Some(d) = crate::dialogs::pan_delta(&ctx, rect, hand) {
let d = xf.unmap_vec(d) / view.zoom;
let d = xf.unmap_vec(d) / (view.zoom / ppp);
view.center[0] -= d.x;
view.center[1] -= d.y;
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
@@ -2468,7 +2483,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
}
if tool == Tool::Hand && response.dragged() {
let d = xf.unmap_vec(response.drag_delta()) / view.zoom;
let d = xf.unmap_vec(response.drag_delta()) / (view.zoom / ppp);
view.center[0] -= d.x;
view.center[1] -= d.y;
} else if primary {
@@ -2667,7 +2682,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
Tool::Zoom => {
let nz = crate::zoom_levels::step(view.zoom, if zoom_out(click_mods.alt) { -1 } else { 1 }, view.doc_size);
let center = app.session.prefs().tools.zoom_clicked_point_to_center;
zoom_about(&mut view, &xf, p, nz, center);
zoom_about(&mut view, &xf, p, nz, center, ppp);
}
// A click with the (temporary) Hand does nothing, never the tool underneath.
Tool::Hand | Tool::RotateView => {}
@@ -2793,7 +2808,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
let cur = app.session.prefs().cursors.clone();
let painting = app.drag.is_some();
let brush = &app.session.tools.brush;
let full = (brush.size / 2.0 * view.zoom).max(1.0);
let full = (brush.size / 2.0 * xf.zoom).max(1.0);
let r = if cur.painting == PaintingCursor::NormalTip { (full * (0.5 + 0.5 * brush.hardness.clamp(0.0, 1.0))).max(1.0) } else { full };
match cur.painting {
PaintingCursor::Standard => egui::CursorIcon::Default,
@@ -2850,11 +2865,11 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
}
Tool::Type | Tool::VerticalType => egui::CursorIcon::Text,
Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, view.zoom),
Tool::MagneticLasso => crate::magnetic_lasso_ui::cursor(app, &painter, p, xf.zoom),
Tool::RedEye => {
let pupil = app.ui.tool_options.red_eye_pupil_size.clamp(1.0, 100.0);
let r_doc = photocraft_algo::redeye::search_radius(pupil);
let r = (r_doc * view.zoom).max(2.0);
let r = (r_doc * xf.zoom).max(2.0);
painter.circle_stroke(p, r + 0.5, Stroke::new(1.0, Color32::from_black_alpha(140)));
painter.circle_stroke(p, r, Stroke::new(1.0, Color32::from_white_alpha(220)));
for (w, c) in [(2.5, Color32::from_black_alpha(140)), (1.0, Color32::from_white_alpha(220))] {
@@ -2904,14 +2919,16 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
/// Zoom to `new_zoom` about the pointer `p`. By default the document point under it stays under
/// it; with Preferences › Tools › Zoom Clicked Point to Center (`center_on_point`) it moves to the
/// centre of the view instead (#204).
fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool) {
fn zoom_about(view: &mut View, xf: &ViewXform, p: Pos2, new_zoom: f32, center_on_point: bool, ppp: f32) {
let before = xf.to_doc(p);
view.zoom = new_zoom;
if center_on_point {
view.center = [before[0] as f32, before[1] as f32];
return;
}
let u = xf.unmap_vec((p - xf.rect.center()) / new_zoom);
// `new_zoom` is device px per document pixel; the pointer vector is in egui points.
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
let u = xf.unmap_vec((p - xf.rect.center()) / (new_zoom / ppp));
view.center = [before[0] as f32 - u.x, before[1] as f32 - u.y];
}
@@ -3448,7 +3465,7 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui:
if tool == Tool::Pen {
crate::vector_ui::pen_move(app, x, y);
}
let zoom = app.current_zoom();
let zoom = app.point_zoom();
if let Some(d) = app.drag.as_mut().filter(|d| d.reposition) {
d.track(mods);
d.shift_to([x, y]);
@@ -3735,7 +3752,7 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers)
if tool == Tool::Pen {
crate::vector_ui::pen_up(app);
}
let zoom = app.current_zoom();
let zoom = app.point_zoom();
let Some(mut d) = app.drag.take() else { return };
d.track(mods);
if d.reposition {
@@ -3987,7 +4004,7 @@ fn polygon_click(app: &mut PhotocraftApp, x: f64, y: f64, mods: egui::Modifiers)
.ui
.polygon
.first()
.is_some_and(|p0| app.ui.polygon.len() >= 3 && ((p0[0] - x).powi(2) + (p0[1] - y).powi(2)).sqrt() < 8.0 / app.current_zoom().max(0.01) as f64);
.is_some_and(|p0| app.ui.polygon.len() >= 3 && ((p0[0] - x).powi(2) + (p0[1] - y).powi(2)).sqrt() < 8.0 / app.point_zoom().max(0.01) as f64);
if close {
commit_polygon(app);
return;
@@ -4905,6 +4922,57 @@ mod tests {
}
}
#[test]
fn a_one_pixel_selection_traces_at_hundred_percent_on_a_scaled_display() {
// #1943 review: the outline step is display resolution in device pixels, not points, so a
// 1 px selection still traces at 100 % on a 2x display (a points-based step sampled every
// second pixel and lost it: zero segments).
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
app.run("select.rect", json!({"x": 50, "y": 50, "width": 1, "height": 100})).unwrap();
app.run("view.actualPixels", json!({})).unwrap();
app.sync_views();
let view = app.ui.views[0].clone();
let mut input = egui::RawInput::default();
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(2.0);
let mut out = ctx.run_ui(input, |ui| {
canvas_view(&mut app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), view.clone(), true);
});
out.textures_delta.clear();
assert_eq!(app.ppp, 2.0, "the primary canvas records the display scale");
let segs = app.outline_cache.as_ref().map_or(0, |(_, _, s)| s.len());
assert!(segs > 0, "a 1 px selection traces at 100 % on a 2x display");
}
#[test]
fn a_secondary_canvas_does_not_overwrite_the_main_canvas_scale() {
// #1943 review: a document window can sit on a display with another scale; only the
// primary canvas records `app.ppp`, so the main mapping doesn't change under it.
let ctx = egui::Context::default();
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 300})).unwrap();
app.run("view.actualPixels", json!({})).unwrap();
app.sync_views();
let view = app.ui.views[0].clone();
let frame = |app: &mut PhotocraftApp, ppp: f32, primary: bool| {
let mut input = egui::RawInput::default();
input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp);
let mut out = ctx.run_ui(input, |ui| {
canvas_view(app, ui, 0, Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)), view.clone(), primary);
});
out.textures_delta.clear();
};
frame(&mut app, 2.0, true);
assert_eq!(app.ppp, 2.0);
frame(&mut app, 1.0, false);
assert_eq!(app.ppp, 2.0, "a secondary window's scale doesn't replace the main canvas's");
// The main canvas mapping still uses its own scale: 100 % is 1 doc px per physical px.
let xf = ViewXform::active(&app).unwrap();
let step = xf.to_screen(1.0, 0.0) - xf.to_screen(0.0, 0.0);
assert!((step.x - 0.5).abs() < 1e-3, "{step:?}");
}
#[test]
fn brush_drags_paint_no_stand_in_shape_over_the_canvas() {
// #189: v0.1 drew the stroke being dragged as a foreground-coloured egui polyline as wide
@@ -4998,30 +5066,66 @@ mod tests {
let doc = app.session.active().unwrap().doc.clone();
let mut view = View { fill_pending: true, ..View::default() };
fill_view(&mut view, &doc, vec2(600.0, 600.0));
fill_view(&mut view, &doc, vec2(600.0, 600.0), 1.0);
assert_eq!(view.zoom, 3.0, "the short edge fills the available height");
assert_eq!(view.center, [200.0, 100.0]);
assert!(!view.fill_pending);
// A scaled display stores the physical factor: the same point zoom is 2× the device px.
let mut view = View { fill_pending: true, ..View::default() };
fill_view(&mut view, &doc, vec2(600.0, 600.0), 2.0);
assert_eq!(view.zoom, 6.0);
// Fit never magnifies past the 1 point-per-pixel cap; on a scaled display the cap is 2.
let mut view = View { fit_pending: true, ..View::default() };
fit_view(&mut view, &doc, vec2(600.0, 600.0), 1.0);
assert_eq!(view.zoom, 1.0);
let mut view = View { fit_pending: true, ..View::default() };
fit_view(&mut view, &doc, vec2(600.0, 600.0), 2.0);
assert_eq!(view.zoom, 2.0);
}
#[test]
fn a_hundred_percent_is_one_physical_pixel_per_document_pixel() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
app.sync_views();
for ppp in [1.0f32, 1.25, 2.0] {
app.ppp = ppp;
let xf = ViewXform::active(&app).unwrap();
// 100% zoom: one document pixel is one physical pixel, i.e. 1/ppp egui points.
let step = xf.to_screen(1.0, 0.0) - xf.to_screen(0.0, 0.0);
// `to_screen` differences around the view centre cancel in f32; 1e-3 is still exact
// enough to see a wrong ppp factor (which would be a 20–100% error).
assert!((step.x - 1.0 / ppp).abs() < 1e-3, "ppp {ppp}: {step:?} want {:?}", 1.0 / ppp);
assert!((app.point_zoom() - 1.0 / ppp).abs() < 1e-5, "ppp {ppp}: {}", app.point_zoom());
// Screen ↔ document stays an exact round trip.
let back = xf.to_doc(xf.to_screen(123.0, 45.0));
assert!((back[0] - 123.0).abs() < 1e-3 && (back[1] - 45.0).abs() < 1e-3, "ppp {ppp}: {back:?}");
}
}
#[test]
fn zoom_about_centres_the_clicked_point_with_the_preference() {
let rect = Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0));
let xf = ViewXform { rect, zoom: 1.0, center: [400.0, 300.0], flip: false, rotation: 0.0 };
let p = pos2(600.0, 200.0);
let doc = xf.to_doc(p);
// Off: the document point under the pointer stays under it.
let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() };
zoom_about(&mut view, &xf, p, 2.0, false);
let after = ViewXform { rect, zoom: 2.0, center: view.center, flip: false, rotation: 0.0 };
let s = after.to_screen(doc[0] as f32, doc[1] as f32);
assert!((s - p).length() < 0.5, "{s:?} vs {p:?}");
// On: the clicked point is the view centre.
let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() };
zoom_about(&mut view, &xf, p, 2.0, true);
assert!((view.center[0] - doc[0] as f32).abs() < 0.5, "{:?}", view.center);
assert!((view.center[1] - doc[1] as f32).abs() < 0.5, "{:?}", view.center);
for ppp in [1.0f32, 2.0] {
// `View::zoom` is physical; the transform works in points.
let xf = ViewXform { rect, zoom: 1.0 / ppp, center: [400.0, 300.0], flip: false, rotation: 0.0 };
let doc = xf.to_doc(p);
// Off: the document point under the pointer stays under it.
let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() };
zoom_about(&mut view, &xf, p, 2.0, false, ppp);
let after = ViewXform { rect, zoom: 2.0 / ppp, center: view.center, flip: false, rotation: 0.0 };
let s = after.to_screen(doc[0] as f32, doc[1] as f32);
assert!((s - p).length() < 0.5, "ppp {ppp}: {s:?} vs {p:?}");
// On: the clicked point is the view centre.
let mut view = View { zoom: 1.0, center: [400.0, 300.0], ..Default::default() };
zoom_about(&mut view, &xf, p, 2.0, true, ppp);
assert!((view.center[0] - doc[0] as f32).abs() < 0.5, "ppp {ppp}: {:?}", view.center);
assert!((view.center[1] - doc[1] as f32).abs() < 0.5, "ppp {ppp}: {:?}", view.center);
}
}
#[test]
+1 -1
View File
@@ -385,7 +385,7 @@ pub fn press(app: &mut PhotocraftApp) -> bool {
}
fn tolerance(app: &PhotocraftApp) -> f64 {
HANDLE_PX / (app.current_zoom() as f64).max(0.01)
HANDLE_PX / (app.point_zoom() as f64).max(0.01)
}
/// The pending frame's angle (degrees), 0 when it isn't a finite number.
+2 -2
View File
@@ -191,7 +191,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
/// A press: pick what's under the pointer and start a drag. False lets the Pen handle a ⌥-press
/// that hit nothing.
fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -> bool {
let tol = 6.0 / f64::from(app.current_zoom().max(0.01));
let tol = 6.0 / f64::from(app.point_zoom().max(0.01));
let ds = &app.ui.direct_selection;
let found = candidates(app).into_iter().find_map(|(r, path)| {
let handles = if ds.target == Some(r) { shown_handles(ds, &path) } else { Vec::new() };
@@ -251,7 +251,7 @@ fn press(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers, convert: bool) -
/// End of an edit drag: commit it as one command. A click (no movement) commits nothing, except
/// Pen + ⌥ on an anchor, which makes it a corner.
fn release(app: &mut PhotocraftApp, target: PathRef, edit: &Edit, start: [f64; 2], end: [f64; 2]) {
let moved = (end[0] - start[0]).hypot(end[1] - start[1]) * f64::from(app.current_zoom()) >= 1.0;
let moved = (end[0] - start[0]).hypot(end[1] - start[1]) * f64::from(app.point_zoom()) >= 1.0;
let (id, mut params) = match edit {
Edit::Convert([s, k]) if !moved => ("path.convertPoint", json!({"subpath": s, "knot": k})),
_ if !moved => return,
+1 -1
View File
@@ -217,6 +217,6 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
/// Screen distance (document px) that counts as "on" a handle at the current zoom.
pub(crate) fn tolerance(app: &PhotocraftApp) -> f64 {
let z = app.session.active_index().and_then(|i| app.ui.views.get(i)).map_or(1.0, |v| v.zoom.max(0.01));
let z = app.point_zoom().max(0.01);
8.0 / f64::from(z)
}
+4 -3
View File
@@ -1637,9 +1637,10 @@ impl CanvasCallback {
let p = &self.params;
let (origin, scale) = self.placement(ppp);
let (mode, lod) = filter_mode(scale);
// The pixel grid shows above 500% and lightens a dark pixel by about a quarter (the
// constants are shared with the CPU path, pixel_grid.rs).
let grid = if p.pixel_grid && crate::pixel_grid::shows_at(p.zoom) { crate::pixel_grid::STRENGTH } else { 0.0 };
// The pixel grid shows above 500% (the constants are shared with the CPU path,
// pixel_grid.rs). `p.zoom` is screen points per document pixel; the shared threshold is
// in device pixels, so scale by the display's pixels per point.
let grid = if p.pixel_grid && crate::pixel_grid::shows_at(p.zoom * ppp) { crate::pixel_grid::STRENGTH } else { 0.0 };
let square = if style.checker_square > 0.0 { (style.checker_square * ppp).round().max(1.0) } else { 0.0 };
let (l, d, g) = (style.checker_light, style.checker_dark, style.gamut_color);
// 32-bit preview: linear-light gain 2^exposure (0 = off) and 1 / gamma.
+3 -3
View File
@@ -210,7 +210,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
app.gradient.drag = None;
return false;
}
let zoom = app.current_zoom().max(0.01);
let zoom = app.point_zoom().max(0.01);
match ev {
ToolEvent::Down { x, y, .. } => {
let p = [x as f32, y as f32];
@@ -231,7 +231,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) ->
}
fn drag_to(app: &mut PhotocraftApp, p: [f32; 2], mods: egui::Modifiers) {
let zoom = app.current_zoom().max(0.01);
let zoom = app.point_zoom().max(0.01);
let active = active_gradient(app);
let Some(drag) = app.gradient.drag.as_mut() else { return };
// ⇧: 45° gradient angles, the same snap as the classic drag (stroke_constraint.rs).
@@ -284,7 +284,7 @@ fn drag_to(app: &mut PhotocraftApp, p: [f32; 2], mods: egui::Modifiers) {
fn finish(app: &mut PhotocraftApp) {
let Some(drag) = app.gradient.drag.take() else { return };
let zoom = app.current_zoom().max(0.01);
let zoom = app.point_zoom().max(0.01);
match drag {
Drag::Draw { from, to, redraw } => {
if dist(from, to) * zoom < 2.0 {
+1 -1
View File
@@ -122,7 +122,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
ToolEvent::Down { .. } => {
if active(app) {
// A click on the first point, or on the last one again, closes the outline.
let reach = 8.0 / app.current_zoom().max(0.01) as f64;
let reach = 8.0 / app.point_zoom().max(0.01) as f64;
let near = |q: [f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) < reach;
let close = app.drag.as_ref().is_some_and(|d| d.points.len() >= 3 && (near(d.start) || d.points.last().is_some_and(|l| near([l[0], l[1]]))));
if close {
+19 -1
View File
@@ -396,6 +396,10 @@ pub struct PhotocraftApp {
fonts_ready: bool,
/// Screen rect of the main canvas last frame (for overlays and the navigator).
pub last_canvas_rect: egui::Rect,
/// Physical pixels per egui point of the canvas last frame (`ctx.pixels_per_point`). The
/// canvas maps document pixels to physical pixels, so point-space geometry divides the view
/// zoom by this (see [`Self::point_zoom`]).
pub ppp: f32,
/// The document area showing the active document's canvas last frame (not the tabs, the
/// start screen or an opening file's card): files dropped here are placed as layers.
pub(crate) drop_canvas_rect: Option<egui::Rect>,
@@ -549,6 +553,7 @@ impl PhotocraftApp {
last_window_title: String::new(),
fonts_ready: false,
last_canvas_rect: egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)),
ppp: 1.0,
drop_canvas_rect: None,
tab_strip: None,
drop_places: Default::default(),
@@ -1478,10 +1483,23 @@ impl PhotocraftApp {
}
impl PhotocraftApp {
/// Zoom of the active document's main view (screen points per document pixel).
/// Zoom of the active document's main view: screen (device) pixels per document pixel, the
/// user-facing factor (`100%` is `1.0`).
pub fn current_zoom(&self) -> f32 {
self.session.active_index().and_then(|i| self.ui.views.get(i)).map_or(1.0, |v| v.zoom)
}
/// Screen (device) pixels per egui point of the canvas (`ctx.pixels_per_point`), as of the
/// last canvas frame. `1.0` before the first frame.
pub fn canvas_ppp(&self) -> f32 {
if self.ppp.is_finite() && self.ppp > 0.0 { self.ppp } else { 1.0 }
}
/// The active view's zoom in egui points per document pixel: the unit every screen-space
/// distance, tolerance and texture-level choice on the canvas is measured in.
pub fn point_zoom(&self) -> f32 {
self.current_zoom() / self.canvas_ppp()
}
}
impl PhotocraftApp {
+2 -2
View File
@@ -73,12 +73,12 @@ fn settings(app: &PhotocraftApp, pressure: f32) -> Settings {
fn spacing(app: &PhotocraftApp) -> f64 {
let f = app.ui.tool_options.magnetic_frequency;
let f = if f.is_finite() { f64::from(f.clamp(0.0, 100.0)) } else { 57.0 };
(8.0 + (100.0 - f) * 0.9) / f64::from(app.current_zoom().max(0.01))
(8.0 + (100.0 - f) * 0.9) / f64::from(app.point_zoom().max(0.01))
}
/// Within this distance of the first point (document px), a click closes the border.
fn close_radius(app: &PhotocraftApp) -> f64 {
8.0 / f64::from(app.current_zoom().max(0.01))
8.0 / f64::from(app.point_zoom().max(0.01))
}
fn dist(a: [f64; 2], b: [f64; 2]) -> f64 {
+2 -1
View File
@@ -389,7 +389,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
let visible = !external
&& app.session.active_index().zip(app.session.clipboard.as_ref()).is_some_and(|(i, clip)| {
let v = &app.ui.views[i];
let (hw, hh) = (app.last_canvas_rect.width() / 2.0 / v.zoom, app.last_canvas_rect.height() / 2.0 / v.zoom);
let pz = app.point_zoom();
let (hw, hh) = (app.last_canvas_rect.width() / 2.0 / pz, app.last_canvas_rect.height() / 2.0 / pz);
let r =
photocraft_geom::Rect::new((v.center[0] - hw) as i32, (v.center[1] - hh) as i32, (v.center[0] + hw) as i32, (v.center[1] + hh) as i32);
!clip.bounds.intersect(&r).is_empty()
+1 -1
View File
@@ -142,7 +142,7 @@ fn drag_to(app: &mut PhotocraftApp, p: [f64; 2], mods: egui::Modifiers) -> [f64;
d = constrain(d, app.move_mods.last, if pixels { TRANSFORM_DIRECTIONS } else { MOVE_DIRECTIONS });
}
app.move_mods.last = Some(d);
let zoom = f64::from(app.current_zoom().max(0.01));
let zoom = f64::from(app.point_zoom().max(0.01));
if app.move_mods.alt && !pixels && app.move_mods.dup_from.is_none() && d[0].hypot(d[1]) * zoom >= DUPLICATE_THRESHOLD {
app.move_mods.dup_from = duplicate(app);
// Only once per drag, even if duplicating failed.
+3 -2
View File
@@ -1468,8 +1468,9 @@ fn navigator(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// Visible-area rectangle.
let v = app.ui.views[idx].clone();
let canvas = app.last_canvas_rect;
let vw = canvas.width() / v.zoom * s;
let vh = canvas.height() / v.zoom * s;
let point_zoom = (v.zoom / app.canvas_ppp()).max(1e-6);
let vw = canvas.width() / point_zoom * s;
let vh = canvas.height() / point_zoom * s;
let c = pos2(rect.min.x + v.center[0] * s, rect.min.y + v.center[1] * s);
let vr = Rect::from_center_size(c, vec2(vw, vh)).intersect(frame.shrink(1.0));
ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::from_rgb(255, 84, 84)), StrokeKind::Middle);
+1 -1
View File
@@ -63,7 +63,7 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Do
if app.patch_preview.as_ref()?.offsets.last().map(|o| o.0) != Some(offset) {
let t0 = crate::gpu_canvas::now_ms();
// Zoomed out, a finer colour fit than the screen shows is wasted.
let min_step = (1.0 / app.current_zoom().max(1e-3)).floor().clamp(1.0, 64.0) as u32;
let min_step = (1.0 / app.point_zoom().max(1e-3)).floor().clamp(1.0, 64.0) as u32;
let params = json!({"offset": offset, "mode": mode, "target": crate::canvas::paint_target(app)});
let result = photocraft_engine::retouch_cmds::patch_preview(&app.session, &params, MAX_CELLS, min_step);
let p = app.patch_preview.as_mut()?;
+2 -1
View File
@@ -33,7 +33,8 @@ const REGION_SNAP: i32 = 64;
/// produce one segment per cell; past this the grid is simply left off the remainder.
const MAX_SEGMENTS: usize = 60_000;
/// Whether the grid is drawn at `zoom`.
/// Whether the grid is drawn at `zoom`, in device (physical) pixels per document pixel: the
/// canvas view zoom on the CPU path, the shader's point scale times `pixels_per_point` on the GPU.
pub fn shows_at(zoom: f32) -> bool {
zoom > MIN_ZOOM
}
+7 -1
View File
@@ -515,7 +515,13 @@ fn empty(ui: &mut egui::Ui, s: &str) {
fn doc_point(app: &PhotocraftApp, pos: Pos2) -> Option<[f64; 2]> {
let i = app.session.active_index()?;
let v = app.ui.views.get(i)?;
let xf = crate::canvas::ViewXform { rect: app.last_canvas_rect, zoom: v.zoom, center: v.center, flip: app.ui.view.flip_horizontal, rotation: v.rotation };
let xf = crate::canvas::ViewXform {
rect: app.last_canvas_rect,
zoom: v.zoom / app.canvas_ppp(),
center: v.center,
flip: app.ui.view.flip_horizontal,
rotation: v.rotation,
};
Some(xf.to_doc(pos))
}
+1 -1
View File
@@ -180,7 +180,7 @@ pub fn guide_at(app: &PhotocraftApp, x: f64, y: f64) -> Option<(bool, usize)> {
return None;
}
let doc = &app.session.active()?.doc;
let tol = 4.0 / app.current_zoom().max(0.01) as f64;
let tol = 4.0 / app.point_zoom().max(0.01) as f64;
for (i, g) in doc.guides.vertical.iter().enumerate() {
if (*g as f64 - x).abs() <= tol {
return Some((true, i));
+11 -8
View File
@@ -87,14 +87,15 @@ pub fn clamp_axis(center: f32, len: f32, size: f32, zoom: f32) -> f32 {
center.clamp(a.min(b), a.max(b))
}
/// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points. True
/// when the view moved (the caller then repaints).
pub fn clamp_view(view: &mut View, size: egui::Vec2) -> bool {
/// Apply Preferences › Tools › Overscroll (off) to `view` for a canvas of `size` points on a
/// display with `ppp` physical pixels per point. True when the view moved (then repaint).
pub fn clamp_view(view: &mut View, size: egui::Vec2, ppp: f32) -> bool {
let [w, h] = view.doc_size;
if w == 0 || h == 0 {
return false;
}
let c = [clamp_axis(view.center[0], w as f32, size.x, view.zoom), clamp_axis(view.center[1], h as f32, size.y, view.zoom)];
let zoom = view.zoom / if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
let c = [clamp_axis(view.center[0], w as f32, size.x, zoom), clamp_axis(view.center[1], h as f32, size.y, zoom)];
// Ignore sub-pixel float noise so the clamp never keeps requesting frames.
let moved = (c[0] - view.center[0]).abs() > 1e-3 || (c[1] - view.center[1]).abs() > 1e-3;
if moved {
@@ -127,7 +128,9 @@ pub fn bar_rects(rect: Rect, horizontal: bool, vertical: bool) -> (Option<Rect>,
/// Returns true when the pointer is over a bar (the canvas then leaves the pointer alone).
pub fn show(ui: &Ui, rect: Rect, view: &mut View, flip: bool, overscroll: bool, key: Id) -> bool {
let [w, h] = view.doc_size;
let zoom = view.zoom;
// `Span` works in egui points; `View::zoom` is device pixels per document pixel.
let ppp = ui.ctx().pixels_per_point();
let zoom = view.zoom / if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
// Horizontal axis in "screen order": with the view flipped, run the image backwards.
let hx = |c: f32| if flip { w as f32 - c } else { c };
let with_extent = |s: Span| (s, s.extent(overscroll));
@@ -285,9 +288,9 @@ mod tests {
assert_eq!(clamp_axis(1400.0, 1000.0, 500.0, 0.2), 1250.0, "its left edge on the view's");
assert_eq!(clamp_axis(500.0, 1000.0, 1000.0, 1.0), 500.0, "exactly the view's size: centred");
let mut v = View { zoom: 1.0, center: [-500.0, 20.0], fit_pending: false, fill_pending: false, doc_size: [1000, 800], rotation: 0.0 };
assert!(clamp_view(&mut v, vec2(500.0, 400.0)));
assert!(clamp_view(&mut v, vec2(500.0, 400.0), 1.0));
assert_eq!(v.center, [250.0, 200.0]);
assert!(!clamp_view(&mut v, vec2(500.0, 400.0)), "stable: no repaint loop");
assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0), "stable: no repaint loop");
}
#[test]
@@ -304,7 +307,7 @@ mod tests {
assert!(at.is_finite() && len.is_finite());
assert_eq!(s.doc_per_point((0.0, 1000.0), 0.0), 0.0);
let mut v = View { zoom: 1.0, center: [0.0, 0.0], fit_pending: false, fill_pending: false, doc_size: [0, 0], rotation: 0.0 };
assert!(!clamp_view(&mut v, vec2(500.0, 400.0)));
assert!(!clamp_view(&mut v, vec2(500.0, 400.0), 1.0));
}
mod canvas {
+1 -1
View File
@@ -56,7 +56,7 @@ pub fn options(app: &PhotocraftApp) -> SnapOptions {
/// Snap tolerance in document pixels at the current zoom.
pub fn tolerance(app: &PhotocraftApp) -> f64 {
SNAP_PX / app.current_zoom().max(0.01) as f64
SNAP_PX / app.point_zoom().max(0.01) as f64
}
fn smart_on(app: &PhotocraftApp) -> bool {
+3 -1
View File
@@ -381,7 +381,9 @@ pub enum DialogKind {
/// Per-document view (camera) state.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct View {
/// Screen pixels per document pixel.
/// Screen (device) pixels per document pixel: the user-facing zoom (`100%` is `1.0`).
/// Canvas geometry works in egui points and divides by `ctx.pixels_per_point`
/// (`PhotocraftApp::point_zoom`), so a scaled display doesn't magnify the image.
pub zoom: f32,
/// Document-space point shown at the canvas centre.
pub center: [f32; 2],
+1 -1
View File
@@ -77,7 +77,7 @@ pub const HANDLE_PX: f64 = 12.0;
/// [`HANDLE_PX`] in document pixels at the current zoom.
pub fn handle_tolerance(app: &PhotocraftApp) -> f64 {
HANDLE_PX / app.current_zoom().max(0.01) as f64
HANDLE_PX / app.point_zoom().max(0.01) as f64
}
/// The box as one undo step restores it.
+3 -3
View File
@@ -54,7 +54,7 @@ fn to_text(aff: &Affine, x: f64, y: f64) -> (f32, f32) {
/// Topmost visible type layer whose laid-out text contains the document point.
fn hit_layer(app: &mut PhotocraftApp, x: f64, y: f64) -> Option<LayerId> {
let doc = app.session.active()?.doc.clone();
let slop = 6.0 / app.current_zoom().max(0.01);
let slop = 6.0 / app.point_zoom().max(0.01);
let mut ids: Vec<LayerId> =
doc.walk().into_iter().filter(|(_, _, l)| l.visible && matches!(l.content, LayerContent::Text(_))).map(|(_, _, l)| l.id).collect();
ids.reverse(); // walk() is bottom-up; hit the topmost first
@@ -160,7 +160,7 @@ pub(crate) fn box_handle_at(app: &mut PhotocraftApp, id: LayerId, x: f64, y: f64
let (r, b) = (bx + w, by + h);
let (mx, my) = (bx + w / 2.0, by + h / 2.0);
let spots = [(bx, by), (r, by), (r, b), (bx, b), (mx, by), (r, my), (mx, b), (bx, my)];
let tol = f64::from(6.0 / app.current_zoom().max(0.01));
let tol = f64::from(6.0 / app.point_zoom().max(0.01));
spots
.iter()
.position(|&(sx, sy)| {
@@ -254,7 +254,7 @@ pub fn pointer_up(app: &mut PhotocraftApp, start: [f64; 2], end: [f64; 2]) {
return;
}
let (w, h) = ((end[0] - start[0]).abs(), (end[1] - start[1]).abs());
let min = 4.0 / app.current_zoom().max(0.01) as f64;
let min = 4.0 / app.point_zoom().max(0.01) as f64;
let o = app.ui.tool_options.clone();
// Preferences ▸ Type: "Fill new type layers with placeholder text" (on by default).
let text = if app.session.prefs().type_.fill_new_type_layers_with_placeholder { PLACEHOLDER } else { "" };
+3 -2
View File
@@ -50,7 +50,7 @@ fn xf(app: &PhotocraftApp) -> ViewXform {
let v = &app.ui.views[0];
ViewXform {
rect: crate::rulers::content_rect(app, app.last_canvas_rect),
zoom: v.zoom,
zoom: v.zoom / app.canvas_ppp(),
center: v.center,
flip: app.ui.view.flip_horizontal,
rotation: v.rotation,
@@ -616,7 +616,8 @@ fn frame_point(app: &PhotocraftApp, p: [f64; 2]) -> Pos2 {
fn assert_affine(a: Affine, b: Affine) {
for (x, y) in a.m.into_iter().zip(b.m) {
assert!((x - y).abs() < 2e-4, "{a:?} != {b:?}");
// Translations around 500 doc px at a scaled ppp cancel to ~2.7e-4 in f32 rounding.
assert!((x - y).abs() < 1e-3, "{a:?} != {b:?}");
}
}
+1 -1
View File
@@ -139,7 +139,7 @@ fn near_border(t: &TransformSession, p: [f64; 2], tol: f64) -> bool {
fn hit(app: &PhotocraftApp, t: &TransformSession, p: [f64; 2]) -> Option<Hit> {
let h = crate::transform_tool::hit(t, p, crate::transform_tool::handle_tolerance(app));
(h != Hit::Outside || near_border(t, p, ROTATE_PX / f64::from(app.current_zoom().max(0.01)))).then_some(h)
(h != Hit::Outside || near_border(t, p, ROTATE_PX / f64::from(app.point_zoom().max(0.01)))).then_some(h)
}
/// Type uses legacy scaling: Shift constrains; Ctrl/Cmd is the mode key, never corner Distort.
+1 -1
View File
@@ -132,7 +132,7 @@ fn pen_to_json(pen: &PenPath, closed: bool) -> Value {
/// Pen press: close on the first anchor, reshape the last anchor's outgoing handle,
/// or add a new anchor (dragging a new anchor pulls symmetrical handles).
pub fn pen_down(app: &mut PhotocraftApp, x: f64, y: f64) {
let tol = 6.0 / app.current_zoom().max(0.01) as f64;
let tol = 6.0 / app.point_zoom().max(0.01) as f64;
let pen = app.ui.pen.get_or_insert_with(PenPath::default);
if let Some(first) = pen.knots.first().map(|k| k[0])
&& pen.knots.len() >= 2
+4 -1
View File
@@ -607,8 +607,11 @@ fn fit_layers(app: &mut PhotocraftApp) -> Result<Value, String> {
let size = [st.doc.size.width, st.doc.size.height];
let area = app.last_canvas_rect.size();
let area = if area.x > 50.0 { area } else { egui::vec2(1200.0, 800.0) };
// The area is in egui points; the stored zoom is device pixels per document pixel.
let ppp = app.canvas_ppp();
let points = ((area.x - 40.0) / b.width().max(1) as f32).min((area.y - 40.0) / b.height().max(1) as f32);
let v = &mut app.ui.views[i];
v.zoom = crate::zoom_levels::clamp(((area.x - 40.0) / b.width().max(1) as f32).min((area.y - 40.0) / b.height().max(1) as f32), size);
v.zoom = crate::zoom_levels::clamp(points * ppp, size);
v.center = [(b.x0 + b.x1) as f32 / 2.0, (b.y0 + b.y1) as f32 / 2.0];
v.fit_pending = false;
Ok(json!({"zoom": v.zoom, "bounds": [b.x0, b.y0, b.x1, b.y1]}))
+11 -8
View File
@@ -39,10 +39,12 @@ pub fn scrub_zoom(zoom0: f32, dx: f32, size: [u32; 2]) -> f32 {
crate::zoom_levels::clamp(zoom0 * e.exp2(), size)
}
/// Set `view` to `zoom` with document point `doc` shown at screen point `screen`.
pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], zoom: f32) {
/// Set `view` to `zoom` (device pixels per document pixel) with document point `doc` shown at
/// screen point `screen` (egui points) on a display with `ppp` physical pixels per point.
pub fn anchor_view(view: &mut View, xf: &ViewXform, screen: Pos2, doc: [f64; 2], zoom: f32, ppp: f32) {
view.zoom = zoom;
let d = (screen - xf.rect.center()) / zoom;
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
let d = (screen - xf.rect.center()) / (zoom / ppp);
let dx = if xf.flip { -d.x } else { d.x };
view.center = [doc[0] as f32 - dx, doc[1] as f32 - d.y];
}
@@ -62,14 +64,14 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View
if let Some(p) = pointer.filter(|_| b.dragged || b.stopped) {
match z.rect_to.as_mut() {
Some(to) => *to = p,
None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size)),
None => anchor_view(view, xf, z.anchor, z.doc, scrub_zoom(z.zoom0, p.x - z.anchor.x, view.doc_size), app.ppp),
}
ctx.data_mut(|d| d.insert_temp(id(), z));
}
if b.stopped {
ctx.data_mut(|d| d.remove::<ZoomDrag>(id()));
if let Some(to) = z.rect_to {
zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, ctx.input(|i| i.modifiers.alt));
zoom_to_rect(view, xf, Rect::from_two_pos(z.anchor, to), z.anchor, ctx.input(|i| i.modifiers.alt), app.ppp);
}
}
true
@@ -77,16 +79,17 @@ pub fn drag(app: &PhotocraftApp, ctx: &egui::Context, view: &mut View, xf: &View
/// Scrubby Zoom off: zoom so the dragged rectangle fills the canvas (a tiny one steps the zoom
/// at the press point; ⌥ steps out).
fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: bool) {
fn zoom_to_rect(view: &mut View, xf: &ViewXform, r: Rect, anchor: Pos2, out: bool, ppp: f32) {
if out || r.width() < 4.0 || r.height() < 4.0 {
let doc = xf.to_doc(anchor);
anchor_view(view, xf, anchor, doc, crate::zoom_levels::step(view.zoom, if out { -1 } else { 1 }, view.doc_size));
anchor_view(view, xf, anchor, doc, crate::zoom_levels::step(view.zoom, if out { -1 } else { 1 }, view.doc_size), ppp);
return;
}
// `k` is the ratio of two screen-point lengths, so it scales the zoom in any unit.
let k = (xf.rect.width() / r.width()).min(xf.rect.height() / r.height());
let zoom = crate::zoom_levels::clamp(view.zoom * k, view.doc_size);
let c = xf.to_doc(r.center());
anchor_view(view, xf, xf.rect.center(), c, zoom);
anchor_view(view, xf, xf.rect.center(), c, zoom, ppp);
}
/// Scrubby Zoom off: the zoom rectangle being dragged, as marching ants.
+8 -14
View File
@@ -84,15 +84,9 @@ fn setup(h: &mut Harness, background: &str, tool: &str) {
}
fn xf(h: &Harness) -> ViewXform {
let app = h.state();
let v = &app.ui.views[0];
ViewXform {
rect: photocraft_ui_egui::rulers::content_rect(app, app.last_canvas_rect),
zoom: v.zoom,
center: v.center,
flip: app.ui.view.flip_horizontal,
rotation: v.rotation,
}
// The same mapping the canvas draws with: `View::zoom` is device pixels per document pixel,
// and the transform works in the viewport's egui points.
ViewXform::active(h.state()).expect("an active document view")
}
/// Screen point (egui points) of document point `(x, y)`.
@@ -182,11 +176,11 @@ fn marquee_previews_show_while_dragging_on_white_and_black_at_1x_and_2x() {
}
} else {
// The ellipse touches its box at the edge midpoints: check short arcs there.
let c = screen(&h, 200.0, 150.0);
let (rx, ry) = (100.0, 70.0);
for (dx, dy) in [(0.0, -ry), (rx, 0.0), (0.0, ry), (-rx, 0.0)] {
let m = c + egui::vec2(dx, dy);
let along = if dx == 0.0 { egui::vec2(14.0, 0.0) } else { egui::vec2(0.0, 14.0) };
// The arcs keep a physical length at any scale, so their sagitta stays inside
// `coverage`'s ±2 physical px search.
for (mx, my) in [(200.0, 80.0), (300.0, 150.0), (200.0, 220.0), (100.0, 150.0)] {
let m = screen(&h, mx, my);
let along = if mx == 200.0 { egui::vec2(14.0 / ppp, 0.0) } else { egui::vec2(0.0, 14.0 / ppp) };
let now = coverage(&img, ppp, m - along, m + along, ink(bg));
assert!(now > 0.3, "{tool} on {bg} @{ppp}x at {m:?}: {now:.2} contrasting");
}
+1 -1
View File
@@ -37,7 +37,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- `engine.execute {command, params}`: run any engine or UI command by id. Engine commands run directly with their default params and never open a dialog. Use `ui.menu.invoke` for menu-click behaviour, which opens a command's dialog when no params are given. `params` must be a JSON object (omit it or pass `null` for none); an array, string, number or boolean is an error naming the command, in every transport (control channel, `serve`, MCP and the CLI)
- `engine.commands`: list commands with enablement
- `ui.inspect`: full UI state (tool, panels, views, dialogs, windows, window size). The menu tree is not included; use `ui.menu.list`. `brushPicker` is `null` while closed and `{pos: [x, y], list: {collapsed, filter, view, renaming}}` while the Brush Preset picker is open (coordinates in screen points). `view` holds the View/Window/Type preferences: `screen_mode`, `extras`, `show` and `snap_to` flags, `flip_horizontal`, `arrange` (Window › Arrange layout), pixel aspect, font preview size, language options. `perf.timings.gpuInfo` holds the graphics adapter, backend, driver, the backend chosen at launch and why, the canvas renderer (`gpu`/`cpu`) and, after a device loss, `lost` (`help.systemInfo` returns the same as `info`)
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?}`: change UI state; any other field is an error, checked before anything changes (`theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour)
- `ui.set {tool?, panels?, dockTabs?, dock?, dockWidth?, colorPanel?, maskTarget?, vectorMaskTarget?, selectionMode?, zoom?, center?, rotation?, fit?, theme?, brushSize?, brushSection?, brushTab?, brushesView?, brushPicker?, brushPickerView?, gradientBlendMode?, gradientClassic?}`: change UI state; any other field is an error, checked before anything changes (`zoom` is the canvas zoom where 1 = 100%, one document pixel per physical display pixel, so a scaled display doesn't magnify the image; `theme` is `pro`, `proMedium`, `studio`, `studioLight` or `classic`; `selectionMode` is the selection tools' options-bar mode, 0 New, 1 Add, 2 Subtract, 3 Intersect; `brushSection` indexes the Brush Settings sections, `brushTab` 0 = Brush Settings, 1 = Brushes; `brushesView` and `brushPickerView` are `list` or `grid` for the Brushes panel and the Brush Preset picker; `brushPicker` is `[x, y]` in screen points to open the Brush Preset picker there (as a right-click with a painting tool does) or `null` to close it, and picking a preset in it is `tools.setBrush {"preset": name}`; `dock` is `{order: ["layers", …], heights: {"properties": 180}, collapsed: ["color"]}`, the right-dock groups top to bottom, their heights in points and the groups collapsed to their tab strip; `dockWidth` sets the right dock width in points, clamped to 250..520; `colorPanel` is `{background}`, whether the Color panel edits the background colour)
- `typeTransform` is `null` outside a temporary Ctrl/Cmd Type-tool drag. During a drag it reports its source document/revision, original affine, oriented `frame` (layer, local bounds, document-space corners and pivot), and captured `gesture`. Drive it with `ui.pointer` and `command: true` at `down`; `move`/`up` may release the modifier. The document/history remain unchanged during preview, and `up` applies one `type.edit` within `textEdit`'s session. Pressing Escape during the drag cancels only the preview. This pointer state is never restored from saved UI state.
- `ui.menu.invoke {id, params?, wait?}` / `ui.menu.list`: activate a menu item by id; list the menu tree. A menu item that starts a background job (a filter without a dialog, such as Blur More) replies with the job's result once it has been applied; with `"wait": false` the reply is `{job, pending: true}` at once
- `ui.set` also accepts `gradientBlendMode` (a layer blend mode name, such as `Difference`) and `gradientClassic` (boolean) for the Gradient tool options bar.
+1 -1
View File
@@ -85,7 +85,7 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
| PSD round trip | 169/169 re-import identically; every adjustment layer and blend mode round-trips | high (within corpus) | Floors in `crates/io/tests/corpus.rs`; raise, never lower. Corpora: `cargo xtask corpus --all` (ours: https://github.com/storytold/photocraft-corpus). |
| Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. |
| Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. |
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418) | low–medium | Needs recurring visual QA with realistic documents. |
| UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. |
| Tools | 2026-10-09: Pencil, Mixer Brush, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, single row/column marquee, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. |
| Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. |
| Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. |