Compare commits

...
Author SHA1 Message Date
Claude 95ef559ded Free Transform: a typed W/H stops at the largest document side
1000000 % on any layer could ask the transform for an image millions of
pixels wide, more than can be allocated. The W and H fields now reach as
far as the box stays within 300000 px (the document limit), at most
1000000 %, and a linked change keeps the other side within its limit too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-08 18:15:40 +00:00
Marcelo Cárdenas 04afd93c13 Free Transform: W/H fields reach 1000000% and X/Y the 300000 px document limit (#1267)
The options bar clamped W/H to ±10000% and X/Y to ±30000 px, while the engine takes any scale
and documents go up to 300000 px a side. Overlays on large stitched mosaics need more. The limits
are now named constants: ±1000000% (a 300 px layer can span the largest document) and ±300000 px.
2026-10-08 13:44:51 -03:00
+77 -4
View File
@@ -1268,6 +1268,22 @@ fn readout(t: &TransformSession) -> (f64, f64, f64, f64) {
/// Width of the mode, cancel and commit cluster kept on the right of the options bar.
const ACTIONS_W: f32 = 140.0;
/// Reach of the reference point X/Y fields: the largest document side (`image.canvasSize`'s limit).
const POSITION_LIMIT_PX: f32 = 300_000.0;
/// Reach of the W/H fields. The engine takes any scale; this lets a 30 px layer span the largest
/// document (#1267).
const SCALE_LIMIT_PCT: f32 = 1_000_000.0;
/// The W or H field's limit for a box side of `side` px: as far as the result stays within the
/// largest document side, so a typed scale can't ask for an image too large to allocate.
fn scale_limit_pct(side: f64) -> f32 {
let side = side.abs();
if !side.is_finite() || side < 1e-6 {
return SCALE_LIMIT_PCT;
}
((f64::from(POSITION_LIMIT_PX) / side * 100.0) as f32).clamp(100.0, SCALE_LIMIT_PCT)
}
/// Options bar while transforming: reference point X/Y, W/H %, angle, interpolation, and, pinned
/// to the right, the warp switch, cancel and commit.
pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
@@ -1299,9 +1315,9 @@ fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSes
let mut px = t.pivot[0] as f32;
let mut py = t.pivot[1] as f32;
lbl(ui, "X:");
let rx = crate::widgets::value_field(ui, &mut px, -30000.0..=30000.0, "px", 72.0);
let rx = crate::widgets::value_field(ui, &mut px, -POSITION_LIMIT_PX..=POSITION_LIMIT_PX, "px", 72.0);
lbl(ui, "Y:");
let ry = crate::widgets::value_field(ui, &mut py, -30000.0..=30000.0, "px", 72.0);
let ry = crate::widgets::value_field(ui, &mut py, -POSITION_LIMIT_PX..=POSITION_LIMIT_PX, "px", 72.0);
if (rx.changed() || ry.changed())
&& let Some(s) = app.ui.transform.as_mut()
{
@@ -1311,8 +1327,9 @@ fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSes
}
crate::widgets::vline(ui, 22.0);
let (mut w, mut h) = (sx as f32, sy as f32);
let (wlim, hlim) = (scale_limit_pct(t.rect[2] - t.rect[0]), scale_limit_pct(t.rect[3] - t.rect[1]));
lbl(ui, "W:");
let rw = crate::widgets::value_field(ui, &mut w, -10000.0..=10000.0, "%", 66.0);
let rw = crate::widgets::value_field(ui, &mut w, -wlim..=wlim, "%", 66.0);
let link_id = egui::Id::new("transform-link");
let mut link: bool = ui.data(|d| d.get_temp(link_id)).unwrap_or(true);
if crate::icons::button(ui, if link { "link" } else { "unlink" }, 22.0, link, tl!("Maintain aspect ratio")).clicked() {
@@ -1320,10 +1337,12 @@ fn transform_fields(app: &mut PhotocraftApp, ui: &mut egui::Ui, t: &TransformSes
ui.data_mut(|d| d.insert_temp(link_id, link));
}
lbl(ui, "H:");
let rh = crate::widgets::value_field(ui, &mut h, -10000.0..=10000.0, "%", 66.0);
let rh = crate::widgets::value_field(ui, &mut h, -hlim..=hlim, "%", 66.0);
if rw.changed() || rh.changed() {
let (kx, ky) = if link {
let k = if rw.changed() { w as f64 / sx.max(1e-9) } else { h as f64 / sy.max(1e-9) };
// Linked, the other side follows: keep it within its own limit too.
let k = k.min(f64::from(wlim) / sx.abs().max(1e-9)).min(f64::from(hlim) / sy.abs().max(1e-9));
(k, k)
} else {
(w as f64 / sx.max(1e-9), h as f64 / sy.max(1e-9))
@@ -1594,6 +1613,60 @@ mod tests {
assert!(h.state().ui.transform.is_none());
}
/// Types `text` + Enter into the `n`th numeric field of the transform bar (X, Y, W, H, angle…).
fn type_into_bar_field(n: usize, text: &str) -> TransformSession {
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.ui.transform = Some(session());
let mut h = Harness::builder().with_size(vec2(900.0, 48.0)).build_ui_state(
|ui, app: &mut PhotocraftApp| {
if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
return;
}
ui.horizontal_centered(|ui| options_bar(app, ui));
},
app,
);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio);
h.run_steps(6);
h.get_all_by_role(egui::accesskit::Role::SpinButton).nth(n).unwrap().click();
h.run();
for c in text.chars() {
h.event(egui::Event::Text(c.to_string()));
h.run();
}
h.key_press(egui::Key::Enter);
h.run_steps(2);
h.state().ui.transform.clone().unwrap()
}
/// Large documents need scales past 10000% and positions past 30000 px (#1267).
#[test]
fn transform_fields_reach_large_documents() {
let t = type_into_bar_field(2, "15000");
let (sx, sy, _, _) = readout(&t);
assert!((sx - 15000.0).abs() < 0.5 && (sy - 15000.0).abs() < 0.5, "{sx} {sy}");
assert!((t.quad[1][0] - t.quad[0][0] - 15_000.0).abs() < 1.0, "{:?}", t.quad);
let t = type_into_bar_field(0, "250000");
assert!((t.pivot[0] - 250_000.0).abs() < 1.0, "{:?}", t.pivot);
}
/// A typed scale stops where the box reaches the largest document side, not past what can be
/// allocated: the 100 × 50 px test box tops out at 300000 % wide.
#[test]
fn typed_scales_stay_within_the_largest_document() {
assert_eq!(scale_limit_pct(20.0), SCALE_LIMIT_PCT);
assert_eq!(scale_limit_pct(3000.0), 10_000.0);
assert_eq!(scale_limit_pct(1e9), 100.0);
assert_eq!(scale_limit_pct(0.0), SCALE_LIMIT_PCT);
assert_eq!(scale_limit_pct(f64::NAN), SCALE_LIMIT_PCT);
let t = type_into_bar_field(2, "99999999");
let (sx, sy, _, _) = readout(&t);
let side = t.rect[2] - t.rect[0];
assert!(sx <= f64::from(scale_limit_pct(side)) + 0.5 && sy <= f64::from(scale_limit_pct(t.rect[3] - t.rect[1])) + 0.5, "{sx} {sy}");
}
fn drag(s: &mut TransformSession, from: [f64; 2], to: [f64; 2], mods: egui::Modifiers) {
let g = Gesture { hit: hit(s, from, 8.0), start: from, quad0: s.quad, pivot0: s.pivot };
apply_drag(s, g, to, mods);