//! Objekt-Auswahl und Manipulation (Selektion, Verschieben, Drehen, Skalieren, Kopieren, Einfügen, Umfärben). //! //! Stellt transaktionale Manipulationen für einzelne oder mehrere Striche und geometrische Formen bereit. use glam::Vec2; use std::f32::consts::FRAC_PI_2; use crate::canvas::CanvasState; use crate::document::{Aabb, Page, Stroke}; use crate::t; /// Status des aktiven Ziehvorgangs (Drag-Operation) mit der Maus oder dem Stylus. #[derive(Debug, Clone, PartialEq, Default)] pub enum DragMode { #[default] None, /// Aufziehen eines Auswahlrahmens (Marquee Box) BoxSelect { start_page: Vec2, current_page: Vec2, }, /// Verschieben ausgewählter Objekte Move { start_page: Vec2, initial_strokes: Vec, }, /// Drehen ausgewählter Objekte um das Zentrum der Auswahl Rotate { center_page: Vec2, start_angle: f32, initial_strokes: Vec, }, /// Skalieren ausgewählter Objekte relativ zum gegenüberliegenden Eckpunkt Scale { fixed_corner_page: Vec2, start_pos_page: Vec2, initial_box_size: Vec2, initial_strokes: Vec, /// Seitenverhältnis beibehalten (Schloss der Auswahl oder Shift gedrückt) keep_aspect: bool, }, } /// Skalierungsfaktoren, wenn der Eckgriff von `start` (relativ zur festen Ecke `fixed`) /// nach `current` gezogen wird. Mit `keep_aspect` wird gleichmäßig skaliert: der Faktor ist /// die Projektion der Zugposition auf die Diagonale, damit das Seitenverhältnis bleibt. pub fn scale_factors(fixed: Vec2, start: Vec2, current: Vec2, keep_aspect: bool) -> Vec2 { let orig = start - fixed; let new = current - fixed; if keep_aspect { let len_sq = orig.length_squared(); if len_sq <= 1.0 { return Vec2::ONE; } return Vec2::splat((new.dot(orig) / len_sq).clamp(0.05, 20.0)); } let factor = |n: f32, o: f32| { if o.abs() > 1.0 { (n / o).clamp(0.05, 20.0) } else { 1.0 } }; Vec2::new(factor(new.x, orig.x), factor(new.y, orig.y)) } /// Ergebnis eines Hit-Tests auf Auswahl-Griffe (Handles). #[derive(Debug, Clone, Copy, PartialEq)] pub enum SelectionHandleHit { None, /// Dreh-Griff (oberhalb der Auswahlbox) RotateHandle { center_page: Vec2, }, /// Einer der 4 Eck-Skalierungsgriffe (0: oben-links, 1: oben-rechts, 2: unten-rechts, 3: unten-links) CornerHandle { corner_idx: usize, fixed_corner_page: Vec2, start_pos_page: Vec2, initial_box_size: Vec2, }, /// Klick in den Körper / Innenbereich der Auswahlbox Body, } /// Verwaltet den Zustand der Objektauswahl, des Clipboards und der Manipulationen. #[derive(Debug, Clone, Default)] pub struct SelectionState { pub selected_ids: Vec, pub drag_mode: DragMode, /// Letzte Zugposition, die noch nicht angewendet ist: Der Stift meldet sich öfter, als /// gezeichnet wird; angewendet wird einmal je Bild (siehe `flush_selection_drag`). pub pending_drag: Option, pub clipboard: Vec, /// Von Hand umgeschaltetes Seitenverhältnis-Schloss und die Auswahl (IDs), für die es /// gilt. Ändert sich die Auswahl, gilt wieder die Vorgabe (siehe [`Self::keeps_aspect`]). aspect_lock: Option<(Vec, bool)>, } impl SelectionState { pub fn new() -> Self { Self { selected_ids: Vec::new(), drag_mode: DragMode::None, pending_drag: None, clipboard: Vec::new(), aspect_lock: None, } } /// Ob Skalieren an den Eckgriffen das Seitenverhältnis der Auswahl beibehält. Vorgabe: /// ja, sobald ein Bild ausgewählt ist (Issue #29); per Knopf für diese Auswahl umschaltbar. pub fn keeps_aspect(&self, page: &Page) -> bool { match &self.aspect_lock { Some((ids, locked)) if *ids == self.selected_ids => *locked, // Läuft bei jedem Bild: erst die (wenigen) Bilder suchen, ohne Striche zu kopieren _ => page .strokes .iter() .any(|s| s.is_image() && self.selected_ids.contains(&s.id)), } } /// Schaltet das Seitenverhältnis-Schloss der aktuellen Auswahl um. pub fn toggle_aspect_lock(&mut self, page: &Page) { let locked = !self.keeps_aspect(page); self.aspect_lock = Some((self.selected_ids.clone(), locked)); } /// Prüft, ob aktuell Objekte ausgewählt sind. pub fn is_empty(&self) -> bool { self.selected_ids.is_empty() } /// Anzahl der aktuell ausgewählten Objekte. pub fn count(&self) -> usize { self.selected_ids.len() } /// Leert die aktuelle Auswahl. pub fn clear(&mut self) { self.selected_ids.clear(); self.drag_mode = DragMode::None; } /// Wählt ein einzelnes Objekt aus. pub fn select_single(&mut self, id: u64) { self.selected_ids = vec![id]; } /// Schaltet die Auswahl eines Objekts um (z. B. bei Shift+Klick). pub fn toggle_select(&mut self, id: u64) { if let Some(pos) = self.selected_ids.iter().position(|&x| x == id) { self.selected_ids.remove(pos); } else { self.selected_ids.push(id); } } /// Setzt die Auswahl auf eine Liste von Objekt-IDs. pub fn select_multiple(&mut self, ids: Vec) { self.selected_ids = ids; } /// Berechnet die gemeinsame AABB aller ausgewählten Objekte. pub fn get_selection_aabb(&self, page: &Page) -> Option { // Läuft bei jedem Bild: ohne die ausgewählten Striche zu kopieren let ids: std::collections::HashSet = self.selected_ids.iter().copied().collect(); page.strokes .iter() .filter(|s| ids.contains(&s.id)) .map(|s| s.aabb) .reduce(|a, b| a.union(&b)) } /// Führt Hit-Testing auf die Auswahlgriffe (Drehen, Ecken, Körper) in Bildschirmkoordinaten /// (physische Pixel) durch. Abstände und Trefferradien sind in logischen Punkten festgelegt /// und werden mit `pixels_per_point` skaliert, damit sie zur gezeichneten Überlagerung passen. pub fn hit_test_handles( &self, page: &Page, canvas: &CanvasState, screen_pos: Vec2, pixels_per_point: f32, ) -> SelectionHandleHit { let Some(aabb) = self.get_selection_aabb(page) else { return SelectionHandleHit::None; }; let p_min = canvas.page_to_screen(aabb.min); let p_max = canvas.page_to_screen(aabb.max); let screen_min = Vec2::new(p_min[0].min(p_max[0]), p_min[1].min(p_max[1])); let screen_max = Vec2::new(p_min[0].max(p_max[0]), p_min[1].max(p_max[1])); let screen_center = (screen_min + screen_max) * 0.5; let page_center = (aabb.min + aabb.max) * 0.5; // 1. Drehgriff testen (oberhalb des oberen Randes, wie gezeichnet) let ppp = pixels_per_point; let rot_screen = Vec2::new(screen_center.x, screen_min.y - ROTATE_HANDLE_OFFSET * ppp); if (screen_pos - rot_screen).length() <= HANDLE_HIT_RADIUS * ppp { return SelectionHandleHit::RotateHandle { center_page: page_center, }; } // 2. 4 Eck-Skalierungsgriffe testen let corners_page = [ aabb.min, // 0: oben-links Vec2::new(aabb.max.x, aabb.min.y), // 1: oben-rechts aabb.max, // 2: unten-rechts Vec2::new(aabb.min.x, aabb.max.y), // 3: unten-links ]; let fixed_corners_page = [ aabb.max, // gegenüber 0 -> unten-rechts Vec2::new(aabb.min.x, aabb.max.y), // gegenüber 1 -> unten-links aabb.min, // gegenüber 2 -> oben-links Vec2::new(aabb.max.x, aabb.min.y), // gegenüber 3 -> oben-rechts ]; let corners_screen = [ screen_min, Vec2::new(screen_max.x, screen_min.y), screen_max, Vec2::new(screen_min.x, screen_max.y), ]; for i in 0..4 { if (screen_pos - corners_screen[i]).length() <= HANDLE_HIT_RADIUS * ppp { return SelectionHandleHit::CornerHandle { corner_idx: i, fixed_corner_page: fixed_corners_page[i], start_pos_page: corners_page[i], initial_box_size: aabb.max - aabb.min, }; } } // 3. Körper-Test let margin = 6.0; if screen_pos.x >= screen_min.x - margin && screen_pos.x <= screen_max.x + margin && screen_pos.y >= screen_min.y - margin && screen_pos.y <= screen_max.y + margin { return SelectionHandleHit::Body; } SelectionHandleHit::None } /// Kopiert die aktuell ausgewählten Objekte in das Clipboard. pub fn copy_to_clipboard(&mut self, page: &Page) -> usize { let strokes = page.get_strokes(&self.selected_ids); let count = strokes.len(); self.clipboard = strokes; count } /// Fügt Objekte aus dem Clipboard mit Versatz (+30 Einheiten) ein, vergibt neue IDs und wählt sie aus. pub fn paste_from_clipboard( &mut self, page: &mut Page, at: Option, next_id_gen: &mut impl FnMut() -> u64, ) -> Vec { if self.clipboard.is_empty() { return Vec::new(); } // Mit `at` (lange gedrückt → Einfügen) landet die Mitte der Objekte dort, sonst // ca. 3 mm nach rechts unten versetzt let offset = match (at, Page::selection_aabb(&self.clipboard)) { (Some(at), Some(aabb)) => at - (aabb.min + aabb.max) / 2.0, _ => Vec2::new(30.0, 30.0), }; let mut new_strokes = Vec::with_capacity(self.clipboard.len()); let mut new_ids = Vec::with_capacity(self.clipboard.len()); for stroke in &self.clipboard { let new_id = next_id_gen(); let mut cloned = stroke.clone(); cloned.id = new_id; cloned.translate(offset); new_ids.push(new_id); new_strokes.push(cloned); } page.add_strokes(new_strokes); self.selected_ids = new_ids.clone(); new_ids } /// Löscht die ausgewählten Objekte transaktional aus der Seite. pub fn delete_selected(&mut self, page: &mut Page) -> Vec { if self.selected_ids.is_empty() { return Vec::new(); } let removed = page.remove_strokes_by_ids(&self.selected_ids); self.selected_ids.clear(); self.drag_mode = DragMode::None; removed } /// Färbt alle ausgewählten Objekte auf `new_color` um (inkl. Vertex-Buffer). pub fn recolor_selected(&mut self, page: &mut Page, new_color: [f32; 4]) -> bool { if self.selected_ids.is_empty() { return false; } // Bilder behalten ihre Farben let mut strokes: Vec<_> = page .get_strokes(&self.selected_ids) .into_iter() .filter(|s| !s.is_image()) .collect(); if strokes.is_empty() { return false; } for s in &mut strokes { s.set_color(new_color); } page.modify_strokes(strokes); true } /// Dreht alle ausgewählten Objekte um 90° im Uhrzeigersinn um das Zentrum ihrer AABB. pub fn rotate_selected_90_deg(&mut self, page: &mut Page) -> bool { let Some(aabb) = self.get_selection_aabb(page) else { return false; }; let center = (aabb.min + aabb.max) * 0.5; let mut strokes = page.get_strokes(&self.selected_ids); if strokes.is_empty() { return false; } for s in &mut strokes { s.rotate(center, FRAC_PI_2); } page.modify_strokes(strokes); true } } /// Rendert das Auswahl-Overlay (Bounding Box, Ecken, Dreh-Griff, Marquee Box) über egui. /// Abstand des Drehgriffs über dem Auswahlrahmen (logische Punkte). const ROTATE_HANDLE_OFFSET: f32 = 24.0; /// Trefferradius der Griffe (logische Punkte), groß genug für Finger. const HANDLE_HIT_RADIUS: f32 = 18.0; /// Wandelt Fensterpixel (physisch) in egui-Koordinaten (logische Punkte) um. fn to_ui(screen: [f32; 2], pixels_per_point: f32) -> egui::Pos2 { egui::pos2(screen[0] / pixels_per_point, screen[1] / pixels_per_point) } pub fn render_selection_overlay( ctx: &egui::Context, selection: &SelectionState, page: &Page, canvas: &CanvasState, ) { let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Foreground, egui::Id::new("selection_overlay_layer"), )); // Fensterpixel sind physisch, egui zeichnet in logischen Punkten let ppp = ctx.pixels_per_point(); // 1. Wenn aktuell ein Auswahlrahmen gezogen wird (Marquee Box) if let DragMode::BoxSelect { start_page, current_page, } = selection.drag_mode { let p1 = to_ui(canvas.page_to_screen(start_page), ppp); let p2 = to_ui(canvas.page_to_screen(current_page), ppp); let min_x = p1[0].min(p2[0]); let min_y = p1[1].min(p2[1]); let max_x = p1[0].max(p2[0]); let max_y = p1[1].max(p2[1]); let rect = egui::Rect::from_min_max(egui::pos2(min_x, min_y), egui::pos2(max_x, max_y)); // Halbtransparente Füllung & blauer Auswahlrand painter.rect_filled( rect, egui::Rounding::same(4.0_f32), egui::Color32::from_rgba_unmultiplied(40, 120, 240, 35), ); painter.rect_stroke( rect, egui::Rounding::same(4.0_f32), egui::Stroke::new( 1.5_f32, egui::Color32::from_rgba_unmultiplied(80, 160, 255, 220), ), ); return; } // 2. Wenn Objekte ausgewählt sind, Bounding Box und Griffe zeichnen let Some(aabb) = selection.get_selection_aabb(page) else { return; }; let p_min = to_ui(canvas.page_to_screen(aabb.min), ppp); let p_max = to_ui(canvas.page_to_screen(aabb.max), ppp); let screen_min = egui::pos2(p_min[0].min(p_max[0]), p_min[1].min(p_max[1])); let screen_max = egui::pos2(p_min[0].max(p_max[0]), p_min[1].max(p_max[1])); let rect = egui::Rect::from_min_max(screen_min, screen_max); // Haupt-Auswahlrahmen (leuchtend blau) let border_color = egui::Color32::from_rgb(70, 150, 255); painter.rect_stroke( rect, egui::Rounding::same(3.0_f32), egui::Stroke::new(1.5_f32, border_color), ); // Dreh-Stängel (Stalk) und Drehgriff let top_center = egui::pos2(rect.center().x, rect.min.y); let rot_handle = egui::pos2(rect.center().x, rect.min.y - ROTATE_HANDLE_OFFSET); painter.line_segment( [top_center, rot_handle], egui::Stroke::new(1.2_f32, border_color), ); // Drehgriff-Kreis painter.circle_filled(rot_handle, 6.0_f32, egui::Color32::WHITE); painter.circle_stroke( rot_handle, 6.0_f32, egui::Stroke::new(1.5_f32, egui::Color32::from_rgb(40, 90, 180)), ); // 4 Eck-Skalierungsgriffe (kleine weiße Quadrate mit blauem Rand) let handle_size = 7.0_f32; let corners = [ rect.min, egui::pos2(rect.max.x, rect.min.y), rect.max, egui::pos2(rect.min.x, rect.max.y), ]; for c in corners { let handle_rect = egui::Rect::from_center_size(c, egui::vec2(handle_size, handle_size)); painter.rect_filled( handle_rect, egui::Rounding::same(1.5_f32), egui::Color32::WHITE, ); painter.rect_stroke( handle_rect, egui::Rounding::same(1.5_f32), egui::Stroke::new(1.2_f32, egui::Color32::from_rgb(30, 80, 170)), ); } // Kleines Info-Badge an der Unterkante (z. B. "2 Objekte | [R] Drehen [Entf] Löschen") let badge_text = if selection.count() == 1 { t!("1 Objekt | [R] Drehen [Entf] Löschen").to_string() } else { t!( "{count} Objekte | [R] Drehen [Entf] Löschen", count = selection.count() ) }; let badge_pos = egui::pos2(rect.center().x, rect.max.y + 16.0); painter.text( badge_pos, egui::Align2::CENTER_CENTER, badge_text, egui::FontId::proportional(11.0), egui::Color32::from_gray(220), ); } #[cfg(test)] mod tests { use super::*; use crate::canvas::InkVertex; use crate::document::{PageBackground, StylusSample}; fn make_test_stroke(id: u64, x: f32, y: f32, color: [f32; 4]) -> Stroke { let s1 = StylusSample { position: Vec2::new(x, y), pressure: 0.5, timestamp: 0.0, }; let s2 = StylusSample { position: Vec2::new(x + 50.0, y + 50.0), pressure: 0.5, timestamp: 0.01, }; let v1 = InkVertex::new(x, y, color); let v2 = InkVertex::new(x + 50.0, y + 50.0, color); Stroke::new(id, vec![s1, s2], vec![v1, v2], color) } #[test] fn test_selection_copy_paste_delete() { let mut page = Page::new(1, PageBackground::Blank); let s1 = make_test_stroke(1, 100.0, 100.0, [0.1, 0.1, 0.1, 1.0]); let s2 = make_test_stroke(2, 300.0, 300.0, [0.8, 0.2, 0.2, 1.0]); page.add_stroke(s1); page.add_stroke(s2); let mut sel = SelectionState::new(); sel.select_single(1); assert_eq!(sel.count(), 1); // Copy let copied = sel.copy_to_clipboard(&page); assert_eq!(copied, 1); // Paste let mut next_id = 100; let new_ids = sel.paste_from_clipboard(&mut page, None, &mut || { next_id += 1; next_id }); assert_eq!(new_ids.len(), 1); assert_eq!(page.strokes.len(), 3); assert_eq!(sel.selected_ids, new_ids); // An einer Stelle einfügen: die Mitte der Kopie liegt dort let target = Vec2::new(1000.0, 1500.0); let placed = sel.paste_from_clipboard(&mut page, Some(target), &mut || { next_id += 1; next_id }); let aabb = sel.get_selection_aabb(&page).unwrap(); assert_eq!(sel.selected_ids, placed); assert!(((aabb.min + aabb.max) / 2.0 - target).length() < 0.01); sel.delete_selected(&mut page); sel.selected_ids = new_ids.clone(); // Delete let deleted = sel.delete_selected(&mut page); assert_eq!(deleted.len(), 1); assert_eq!(page.strokes.len(), 2); assert!(sel.is_empty()); } #[test] fn test_selection_recolor_and_rotate() { let mut page = Page::new(1, PageBackground::Blank); let s1 = make_test_stroke(1, 200.0, 200.0, [0.1, 0.1, 0.1, 1.0]); page.add_stroke(s1); let mut sel = SelectionState::new(); sel.select_single(1); // Recolor let new_color = [0.0, 1.0, 0.0, 1.0]; assert!(sel.recolor_selected(&mut page, new_color)); assert_eq!(page.strokes[0].color, new_color); assert_eq!(page.strokes[0].vertices[0].color, new_color); // Undo recolor assert!(page.undo()); assert_eq!(page.strokes[0].color, [0.1, 0.1, 0.1, 1.0]); // Rotate 90 deg let orig_pos = page.strokes[0].points[0].position; assert!(sel.rotate_selected_90_deg(&mut page)); let rotated_pos = page.strokes[0].points[0].position; assert!( (orig_pos.x - rotated_pos.x).abs() > 0.1 || (orig_pos.y - rotated_pos.y).abs() > 0.1 ); // Undo rotate assert!(page.undo()); let restored_pos = page.strokes[0].points[0].position; assert!((orig_pos.x - restored_pos.x).abs() < 1e-4); } #[test] fn test_scale_factors_free_and_keep_aspect() { let fixed = Vec2::new(0.0, 0.0); let start = Vec2::new(200.0, 100.0); // Frei: Breite und Höhe unabhängig let free = scale_factors(fixed, start, Vec2::new(400.0, 100.0), false); assert_eq!(free, Vec2::new(2.0, 1.0)); // Seitenverhältnis fest: gleicher Faktor in beiden Richtungen let locked = scale_factors(fixed, start, Vec2::new(400.0, 100.0), true); assert!((locked.x - locked.y).abs() < 1e-6); assert!((locked.x - 1.8).abs() < 1e-4); // Entlang der Diagonale genau verdoppelt let diag = scale_factors(fixed, start, Vec2::new(400.0, 200.0), true); assert!((diag - Vec2::splat(2.0)).length() < 1e-5); // Über die feste Ecke hinaus: kein Spiegeln let past = scale_factors(fixed, start, Vec2::new(-50.0, -50.0), true); assert_eq!(past, Vec2::splat(0.05)); // Senkrechte Linie (Breite 0): frei bleibt x unverändert, fest skaliert beides let line = Vec2::new(0.0, 100.0); assert_eq!( scale_factors(fixed, line, Vec2::new(30.0, 200.0), false), Vec2::new(1.0, 2.0) ); assert_eq!( scale_factors(fixed, line, Vec2::new(30.0, 200.0), true), Vec2::splat(2.0) ); } #[test] fn test_aspect_lock_defaults_to_images_and_toggles_per_selection() { let mut page = Page::new(1, PageBackground::Blank); let corners = [ Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 50.0), Vec2::new(0.0, 50.0), ]; page.add_stroke(Stroke::new_image(1, "a.png".into(), corners)); page.add_stroke(make_test_stroke(2, 200.0, 200.0, [0.1, 0.1, 0.1, 1.0])); let mut sel = SelectionState::new(); // Vorgabe: Bilder gesperrt, Striche frei sel.select_single(1); assert!(sel.keeps_aspect(&page)); sel.select_single(2); assert!(!sel.keeps_aspect(&page)); // Umschalten gilt für diese Auswahl sel.toggle_aspect_lock(&page); assert!(sel.keeps_aspect(&page)); sel.select_single(1); sel.toggle_aspect_lock(&page); assert!(!sel.keeps_aspect(&page)); sel.toggle_aspect_lock(&page); assert!(sel.keeps_aspect(&page)); sel.toggle_aspect_lock(&page); // Neue Auswahl: wieder die Vorgabe sel.select_multiple(vec![1, 2]); assert!(sel.keeps_aspect(&page)); sel.select_single(1); assert!( !sel.keeps_aspect(&page), "gleiche Auswahl behält das Umschalten" ); sel.clear(); sel.select_single(1); assert!(!sel.keeps_aspect(&page)); } #[test] fn test_recolor_ignores_images() { let mut page = Page::new(1, PageBackground::Blank); let corners = [ Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(100.0, 50.0), Vec2::new(0.0, 50.0), ]; page.add_stroke(Stroke::new_image(1, "a.png".into(), corners)); let undo_depth = page.history.undo_stack.len(); let mut sel = SelectionState::new(); sel.select_single(1); // Nur ein Bild ausgewählt: nichts umzufärben, kein Undo-Eintrag assert!(!sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0])); assert_eq!(page.history.undo_stack.len(), undo_depth); // Bild und Strich: nur der Strich wird umgefärbt page.add_stroke(make_test_stroke(2, 20.0, 20.0, [0.1, 0.1, 0.1, 1.0])); sel.select_multiple(vec![1, 2]); assert!(sel.recolor_selected(&mut page, [1.0, 0.0, 0.0, 1.0])); assert_eq!(page.strokes[0].color, [1.0, 1.0, 1.0, 1.0]); assert_eq!(page.strokes[1].color, [1.0, 0.0, 0.0, 1.0]); } }