fix(ui): rotate cropped image previews around the committed pivot

Project the visible user-space bounds centre for the drag preview, matching the RotateBy engine operation. The full image centre differs for cropped artwork and caused the preview to jump on release.

The regression compares actual painted preview corners with saved/reopened image corners and verifies a requested 90-degree turn on cropped and uncropped controls, including page rotation. Linux scoped clippy, 106 tests, wasm, formatting, layering and assets pass.

Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
This commit is contained in:
FredApps
2026-11-01 17:34:13 +01:00
co-authored by Codex GPT-6.1
parent c0a5a5cbf8
commit fdb6a6f33e
2 changed files with 68 additions and 1 deletions
@@ -761,7 +761,8 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
if view.image_selection.as_ref().is_some_and(|s| s.turning) {
let shift = ui.input(|i| i.modifiers.shift);
let centre = obb.screen_center;
let visible = image.visible_rect.unwrap_or(image.rect);
let centre = user_to_screen(xf, info, page, visible[0] * 0.5 + visible[2] * 0.5, visible[1] * 0.5 + visible[3] * 0.5);
let rot_knob = shown_knob;
let drag_start = view.image_selection.as_ref().and_then(|s| s.drag).map_or(rot_knob, |d| d.start);
let degrees = hover_pos.map_or(0.0, |p| turn_degrees_delta(centre, drag_start, p, shift));
+66
View File
@@ -9549,3 +9549,69 @@ fn affine_review_resize_uses_press_delta_and_affine_frame() {
assert!(placements[0].into_iter().zip(placements[1]).all(|(a, b)| (a - b).abs() < 1e-3), "press offset changed placement: {placements:?}");
}
}
#[test]
fn affine_review_cropped_rotation_preview_matches_saved_corners() {
let matrix = [70.710678, 70.710678, -70.710678, 70.710678, 170.710678, 100.0];
for (clip, rotation) in [(None, 0), (Some([100.0, 170.0, 241.421356, 241.421356]), 0), (Some([100.0, 170.0, 241.421356, 241.421356]), 90)] {
let mut h = affine_review_harness(affine_review_image_fixture(matrix, clip, rotation));
let view = &h.state().views[0];
let id = view.id;
let doc = h.state().session.get(id).unwrap();
let image = doc.page_images(0)[0].clone();
let xf = view.page_xform(0).unwrap();
let obb = pdfcraft_ui_egui::canvas::ImageObb::from_image(&image, &xf, &doc.info, 0);
let visible = image.visible_rect.unwrap_or(image.rect);
let p = doc.info.pages[0].user_to_view(((visible[0] + visible[2]) * 0.5) as f32, ((visible[1] + visible[3]) * 0.5) as f32);
let centre = xf.norm_to_screen(p[0] / xf.pw, p[1] / xf.ph);
let start = obb.s_rot;
let v = start - centre;
let finish = centre + egui::vec2(-v.y, v.x);
h.hover_at(start);
h.run_steps(1);
h.drag_at(start);
h.run_steps(1);
h.hover_at(finish);
h.run_steps(2);
assert!(h.state().views[0].image_selection.as_ref().unwrap().turning);
let preview = h
.output()
.shapes
.iter()
.find_map(|shape| match &shape.shape {
egui::Shape::Path(path) if path.closed && path.points.len() == 4 && path.stroke.width == 1.5 => Some(path.points.clone()),
_ => None,
})
.expect("rotation preview outline");
if clip.is_some()
&& rotation == 0
&& let Ok(directory) = std::env::var("PDFCRAFT_AFFINE_SHOTS")
{
h.render().unwrap().save(std::path::Path::new(&directory).join("affine-rotation-preview.png")).unwrap();
}
h.drop_at(finish);
h.run_steps(3);
if clip.is_some()
&& rotation == 0
&& let Ok(directory) = std::env::var("PDFCRAFT_AFFINE_SHOTS")
{
h.render().unwrap().save(std::path::Path::new(&directory).join("affine-rotation-committed.png")).unwrap();
}
let saved = h.state().session.save_bytes(id).unwrap();
let mut reopened = pdfcraft_engine::Session::new();
let reopened_id = reopened.open("saved.pdf", None, saved, None).unwrap();
let doc = reopened.get(reopened_id).unwrap();
let after = doc.page_images(0)[0].clone();
let committed = pdfcraft_ui_egui::canvas::ImageObb::from_matrix(after.matrix, &xf, &doc.info, 0).corners();
let (cx, cy) = ((visible[0] + visible[2]) * 0.5, (visible[1] + visible[3]) * 0.5);
let expected = pdfcraft_content::Matrix(image.matrix).then(&pdfcraft_content::Matrix([0.0, -1.0, 1.0, 0.0, cx - cy, cy + cx])).0;
assert!(
after.matrix.into_iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-3),
"knob must commit the requested 90-degree turn around visible bounds"
);
assert!(
preview.iter().zip(committed).all(|(a, b)| a.distance(b) < 1.0),
"preview jumped after commit/save: clip={clip:?} rotation={rotation}: {preview:?} != {committed:?}"
);
}
}