From 7e05b33ee3f23e42579a1bda239da23c04049e83 Mon Sep 17 00:00:00 2001 From: Harald Date: Wed, 7 Oct 2026 20:46:08 +0200 Subject: [PATCH] feat: virtuelles Lineal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lineal ein-/ausblenden über die Werkzeugleiste oder Taste L (26 cm, mm-/cm-Skala an beiden Kanten, Winkelanzeige) - Fläche ziehen verschiebt, Griff ⟳ dreht; 15°-Schritte rasten ein - Striche, die bis 6 mm neben einer Längskante beginnen, folgen bis zum Abheben exakt der Kante (Stift, Textmarker, Formen); die Tinte liegt um die halbe Strichbreite neben dem Lineal - Lineal bleibt beim Blättern an seiner Stelle; nur seine Fläche und der Griff nehmen Eingaben an, an den Kanten wird gezeichnet Co-Authored-By: Claude Opus 5.5 --- src/canvas.rs | 28 +++++ src/lib.rs | 1 + src/main.rs | 19 +++ src/ruler.rs | 239 +++++++++++++++++++++++++++++++++++ src/ui.rs | 340 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 627 insertions(+) create mode 100644 src/ruler.rs diff --git a/src/canvas.rs b/src/canvas.rs index eaddbee..f0e065b 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -168,6 +168,11 @@ pub struct CanvasState { // Eingefügte Bilder der aktuellen Seite image_layer: crate::image_layer::ImageLayer, + /// Eingeblendetes Lineal (Striche nahe einer Kante rasten ein) + pub ruler: Option, + /// Kante, an der der aktuelle Live-Strich eingerastet ist + ruler_lock: Option, + // egui UI-Renderer pub egui_renderer: egui_wgpu::Renderer, } @@ -461,6 +466,8 @@ impl CanvasState { committed_vertex_count: 0, committed_strokes: Vec::with_capacity(128), image_layer, + ruler: None, + ruler_lock: None, egui_renderer, camera: crate::viewport::ViewportState::new(), }) @@ -726,6 +733,13 @@ impl CanvasState { self.live_vertices.clear(); self.live_vertex_count = 0; + // Beginnt der Strich an einer Linealkante, bleibt er bis zum Abheben auf ihr + let half_width = self.current_stroke_width_mm * crate::document::UNITS_PER_MM / 2.0; + self.ruler_lock = self + .ruler + .and_then(|r| r.edge_lock(glam::Vec2::new(page_x, page_y), half_width)); + let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y); + // 1. Zwingend durch den 1€-Filter leiten let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp); let pt = ControlPoint::new(fx, fy, fp); @@ -749,6 +763,7 @@ impl CanvasState { } // 1. Zwingend durch den 1€-Filter leiten + let (page_x, page_y) = self.constrain_to_ruler(page_x, page_y); let (fx, fy, fp) = self.filter.filter(page_x, page_y, pressure, timestamp); let new_pt = ControlPoint::new(fx, fy, fp); @@ -771,6 +786,19 @@ impl CanvasState { self.update_live_mesh(); } + /// Projiziert einen Punkt auf die Linealkante, falls der Live-Strich dort eingerastet ist. + fn constrain_to_ruler(&self, x: f32, y: f32) -> (f32, f32) { + match self.ruler_lock { + Some(lock) => lock.project(glam::Vec2::new(x, y)).into(), + None => (x, y), + } + } + + /// Ist der aktuelle Live-Strich an einer Linealkante eingerastet? + pub fn is_ruler_locked(&self) -> bool { + self.ruler_lock.is_some() + } + /// Bricht den aktuellen Live-Strich ab und setzt Puffer sowie 1€-Filter sauber zurück. pub fn cancel_stroke(&mut self) { self.live_samples.clear(); diff --git a/src/lib.rs b/src/lib.rs index d795f45..77f0a34 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub mod page_export; pub mod pdf_doc; pub mod pdf_merge; pub mod platform; +pub mod ruler; pub mod selection; pub mod settings; pub mod spline; diff --git a/src/main.rs b/src/main.rs index 46b2a0a..e714cde 100644 --- a/src/main.rs +++ b/src/main.rs @@ -309,6 +309,16 @@ fn texture_for( } } +/// Abbildung Seite ↔ logische Bildschirmpunkte für die Oberfläche (Lineal). +fn page_view(canvas: &CanvasState, scale_factor: f32) -> ui::PageView { + let origin = canvas.page_to_screen(Vec2::ZERO); + let unit = canvas.page_to_screen(Vec2::new(1000.0, 0.0)); + ui::PageView { + origin: egui::pos2(origin[0] / scale_factor, origin[1] / scale_factor), + scale: (unit[0] - origin[0]) / 1000.0 / scale_factor, + } +} + fn copy_message(count: usize, as_image: bool) -> String { if as_image { format!("{count} Objekt(e) kopiert – auch als Bild für andere Programme") @@ -1246,6 +1256,13 @@ fn main() -> Result<(), Box> { window.request_redraw(); } + // Lineal ein-/ausblenden: L + Key::Character(c) if c.eq_ignore_ascii_case("l") && !ctrl => { + ui_state.toggle_ruler(); + canvas.ruler = ui_state.ruler; + window.request_redraw(); + } + // Bild einfügen: Strg + I Key::Character(c) if c.eq_ignore_ascii_case("i") && ctrl => { ui_state.open_image_picker(); @@ -2240,7 +2257,9 @@ fn main() -> Result<(), Box> { // 3. Toolbar & Widgets rendern updater.update(); + ui_state.page_view = Some(page_view(&canvas, window.scale_factor() as f32)); let actions = ui::render_ui(&egui_ctx, &mut ui_state, ¬ebook, autosave.status(), &updater); + canvas.ruler = ui_state.ruler; // Seite neben der Werkzeugleiste zentrieren (Leistenbreite in physischen Pixeln) let reserved = ui_state.sidebar_reserved_width() * window.scale_factor() as f32; diff --git a/src/ruler.rs b/src/ruler.rs new file mode 100644 index 0000000..6719669 --- /dev/null +++ b/src/ruler.rs @@ -0,0 +1,239 @@ +//! Virtuelles Lineal: ein verschieb- und drehbares Lineal über dem Blatt. Striche, die nahe +//! einer Längskante beginnen, rasten auf dieser Kante ein und werden zu geraden Linien. +//! +//! Alle Maße in Seiteneinheiten (10 Einheiten = 1 mm). + +use glam::Vec2; + +use crate::document::UNITS_PER_MM; + +/// Länge des Lineals (26 cm). +pub const RULER_LENGTH: f32 = 260.0 * UNITS_PER_MM; +/// Breite des Lineals (3,2 cm). +pub const RULER_WIDTH: f32 = 32.0 * UNITS_PER_MM; +/// Abstand zu einer Längskante, innerhalb dessen ein Strich einrastet (innen wie außen). +pub const SNAP_DISTANCE: f32 = 6.0 * UNITS_PER_MM; +/// Radius des Dreh-Griffs. +pub const HANDLE_RADIUS: f32 = 8.0 * UNITS_PER_MM; +/// Winkel rasten in diesen Schritten ein … +pub const ANGLE_SNAP_STEP_DEG: f32 = 15.0; +/// … wenn sie höchstens so weit davon entfernt sind. +pub const ANGLE_SNAP_TOLERANCE_DEG: f32 = 2.0; + +/// Lage des Lineals auf dem Blatt. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Ruler { + /// Mittelpunkt + pub center: Vec2, + /// Drehwinkel der Längsachse in Bogenmaß (0 = waagerecht) + pub angle: f32, +} + +/// Was an einer Stelle des Lineals liegt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RulerHit { + /// Fläche zum Verschieben + Body, + /// Griff zum Drehen + RotateHandle, +} + +/// Eine Kante, an der ein Strich eingerastet ist: Gerade durch `origin` in Richtung `dir`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EdgeLock { + pub origin: Vec2, + pub dir: Vec2, +} + +impl EdgeLock { + /// Fußpunkt von `p` auf der Geraden. + pub fn project(&self, p: Vec2) -> Vec2 { + self.origin + self.dir * (p - self.origin).dot(self.dir) + } +} + +impl Ruler { + pub fn new(center: Vec2) -> Self { + Self { center, angle: 0.0 } + } + + /// Einheitsvektor entlang der Längsachse. + pub fn axis(&self) -> Vec2 { + Vec2::from_angle(self.angle) + } + + /// Einheitsvektor quer zur Längsachse (zur „unteren“ Kante, bei 0° nach unten). + pub fn normal(&self) -> Vec2 { + self.axis().perp() + } + + /// Lage von `p` im Lineal-Koordinatensystem: (entlang der Achse, quer dazu). + pub fn local(&self, p: Vec2) -> Vec2 { + let d = p - self.center; + Vec2::new(d.dot(self.axis()), d.dot(self.normal())) + } + + /// Ecken: oben links, oben rechts, unten rechts, unten links (bei 0°). + pub fn corners(&self) -> [Vec2; 4] { + let a = self.axis() * (RULER_LENGTH / 2.0); + let n = self.normal() * (RULER_WIDTH / 2.0); + [ + self.center - a - n, + self.center + a - n, + self.center + a + n, + self.center - a + n, + ] + } + + /// Mittelpunkt des Dreh-Griffs (nahe dem rechten Ende). + pub fn handle_center(&self) -> Vec2 { + self.center + self.axis() * (RULER_LENGTH / 2.0 - HANDLE_RADIUS * 1.6) + } + + /// Was liegt an `p`? Die Streifen entlang der Kanten gehören nicht dazu: dort beginnen + /// eingerastete Striche. + pub fn hit(&self, p: Vec2) -> Option { + if (p - self.handle_center()).length() <= HANDLE_RADIUS { + return Some(RulerHit::RotateHandle); + } + let l = self.local(p); + let inside_band = l.y.abs() <= RULER_WIDTH / 2.0 - SNAP_DISTANCE; + (l.x.abs() <= RULER_LENGTH / 2.0 && inside_band).then_some(RulerHit::Body) + } + + /// Kante, an der ein bei `p` beginnender Strich einrastet, verschoben um `offset` nach + /// außen (halbe Strichbreite: die Tinte liegt dann neben dem Lineal, nicht darunter). + pub fn edge_lock(&self, p: Vec2, offset: f32) -> Option { + let l = self.local(p); + if l.x.abs() > RULER_LENGTH / 2.0 + SNAP_DISTANCE { + return None; + } + let half = RULER_WIDTH / 2.0; + let side = if (l.y - half).abs() <= SNAP_DISTANCE { + 1.0 + } else if (l.y + half).abs() <= SNAP_DISTANCE { + -1.0 + } else { + return None; + }; + Some(EdgeLock { + origin: self.center + self.normal() * side * (half + offset), + dir: self.axis(), + }) + } + + /// Dreht das Lineal so, dass die Achse vom Mittelpunkt auf `toward` zeigt; Vielfache von + /// 15° rasten ein. + pub fn rotate_toward(&mut self, toward: Vec2) { + let d = toward - self.center; + if d.length_squared() < 1e-6 { + return; + } + self.angle = snap_angle(d.y.atan2(d.x)); + } + + /// Winkel für die Anzeige in Grad, 0–179 (ein Lineal hat keine Richtung). + pub fn display_degrees(&self) -> u32 { + (self.angle.to_degrees().round() as i32).rem_euclid(180) as u32 + } +} + +/// Rastet einen Winkel (Bogenmaß) auf das nächste Vielfache von 15° ein, wenn er nahe genug ist. +pub fn snap_angle(angle: f32) -> f32 { + let deg = angle.to_degrees(); + let nearest = (deg / ANGLE_SNAP_STEP_DEG).round() * ANGLE_SNAP_STEP_DEG; + if (deg - nearest).abs() <= ANGLE_SNAP_TOLERANCE_DEG { + nearest.to_radians() + } else { + angle + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: Vec2, b: Vec2) -> bool { + (a - b).length() < 1e-2 + } + + #[test] + fn test_geometry_and_hits() { + let ruler = Ruler::new(Vec2::new(1000.0, 1000.0)); + let [tl, tr, br, bl] = ruler.corners(); + assert!(close( + tl, + Vec2::new(1000.0 - RULER_LENGTH / 2.0, 1000.0 - RULER_WIDTH / 2.0) + )); + assert!(close( + br, + Vec2::new(1000.0 + RULER_LENGTH / 2.0, 1000.0 + RULER_WIDTH / 2.0) + )); + assert!(close( + tr - tl, + bl - tl + Vec2::new(RULER_LENGTH, -RULER_WIDTH) + )); + + assert_eq!(ruler.hit(ruler.center), Some(RulerHit::Body)); + assert_eq!( + ruler.hit(ruler.handle_center()), + Some(RulerHit::RotateHandle) + ); + // Kantenstreifen und außerhalb: kein Treffer (dort wird gezeichnet) + assert_eq!( + ruler.hit(Vec2::new(1000.0, 1000.0 - RULER_WIDTH / 2.0 + 10.0)), + None + ); + assert_eq!(ruler.hit(Vec2::new(1000.0, 1300.0)), None); + assert_eq!(ruler.hit(Vec2::new(1000.0 + RULER_LENGTH, 1000.0)), None); + } + + #[test] + fn test_edge_lock_projects_onto_offset_edge() { + let ruler = Ruler::new(Vec2::new(1000.0, 1000.0)); + let edge_y = 1000.0 - RULER_WIDTH / 2.0; + // Knapp oberhalb der oberen Kante: rastet ein, Linie liegt um den Versatz außerhalb + let lock = ruler + .edge_lock(Vec2::new(900.0, edge_y - 20.0), 3.0) + .unwrap(); + let p = lock.project(Vec2::new(1300.0, edge_y - 55.0)); + assert!(close(p, Vec2::new(1300.0, edge_y - 3.0))); + // Knapp innerhalb der unteren Kante + let lock = ruler + .edge_lock(Vec2::new(900.0, 1000.0 + RULER_WIDTH / 2.0 - 20.0), 0.0) + .unwrap(); + assert!(close( + lock.project(Vec2::new(0.0, 0.0)), + Vec2::new(0.0, 1000.0 + RULER_WIDTH / 2.0) + )); + // Mitte oder weit weg: kein Einrasten + assert!(ruler.edge_lock(ruler.center, 0.0).is_none()); + assert!(ruler.edge_lock(Vec2::new(1000.0, 500.0), 0.0).is_none()); + assert!(ruler + .edge_lock(Vec2::new(1000.0 + RULER_LENGTH, edge_y), 0.0) + .is_none()); + + // Gedrehtes Lineal: die Linie folgt der Drehung + let mut tilted = ruler; + tilted.angle = 30f32.to_radians(); + let near_edge = tilted.center - tilted.normal() * (RULER_WIDTH / 2.0 + 10.0); + let lock = tilted.edge_lock(near_edge, 0.0).unwrap(); + let a = lock.project(near_edge); + let b = lock.project(near_edge + Vec2::new(300.0, 0.0)); + let dir = (b - a).normalize(); + assert!((dir.y.atan2(dir.x).to_degrees() - 30.0).abs() < 0.01); + } + + #[test] + fn test_rotation_snaps_to_15_degrees() { + let mut ruler = Ruler::new(Vec2::ZERO); + ruler.rotate_toward(Vec2::from_angle(44.0f32.to_radians()) * 100.0); + assert_eq!(ruler.display_degrees(), 45); + ruler.rotate_toward(Vec2::from_angle(37.0f32.to_radians()) * 100.0); + assert_eq!(ruler.display_degrees(), 37); + // Gegenrichtung zeigt denselben Winkel + ruler.rotate_toward(Vec2::from_angle(-150.0f32.to_radians()) * 100.0); + assert_eq!(ruler.display_degrees(), 30); + assert_eq!(snap_angle(1.0f32.to_radians()), 0.0); + } +} diff --git a/src/ui.rs b/src/ui.rs index 640a832..52106b8 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -17,6 +17,7 @@ use crate::document::{ EraserMode, Notebook, Page, PageBackground, StrokeTool, PAGE_HEIGHT, PAGE_WIDTH, }; use crate::file_picker::{FilePicker, PickerKind}; +use crate::ruler::{Ruler, RulerHit}; use crate::settings::{PressureCurve, SidebarSide}; use crate::storage::NotebookSummary; @@ -97,6 +98,37 @@ pub const PEN_WIDTH_PRESETS: [(f32, &str); 4] = [ pub const HIGHLIGHTER_WIDTH_PRESETS: [(f32, &str); 3] = [(4.0, "4.0 mm"), (6.0, "6.0 mm"), (8.0, "8.0 mm")]; +/// Abbildung zwischen Seitenkoordinaten und logischen Bildschirmpunkten (egui), vom +/// Hauptprogramm je Bild aus Ansicht und Zoom gesetzt. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PageView { + /// Bildschirmposition des Seitenursprungs (oben links) + pub origin: egui::Pos2, + /// Bildschirmpunkte je Seiteneinheit + pub scale: f32, +} + +impl PageView { + pub fn to_screen(&self, p: glam::Vec2) -> egui::Pos2 { + self.origin + egui::vec2(p.x, p.y) * self.scale + } + + pub fn to_page(&self, p: egui::Pos2) -> glam::Vec2 { + let d = (p - self.origin) / self.scale.max(1e-6); + glam::Vec2::new(d.x, d.y) + } +} + +/// Laufende Bedienung des Lineals. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum RulerDrag { + /// Verschieben; Abstand vom Griffpunkt zum Mittelpunkt + Move { + grab_offset: glam::Vec2, + }, + Rotate, +} + /// Format für den Export der aktuellen Seite. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PageExportKind { @@ -234,6 +266,13 @@ pub struct UiState { pub last_raw_pressure: Option, /// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“) pub file_picker: Option, + /// Eingeblendetes Lineal + pub ruler: Option, + pub ruler_drag: Option, + /// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests) + pub page_view: Option, + /// Fenstergröße in logischen Punkten (vom letzten Bild) + pub screen_size: egui::Vec2, /// Eingabefeld „Titel dieser Seite“ in der Seitenübersicht und die Seite, zu der es gehört pub page_title_input: String, pub page_title_for: Option, @@ -286,6 +325,10 @@ impl Default for UiState { last_raw_pressure: None, file_picker: None, queued_actions: Vec::new(), + ruler: None, + ruler_drag: None, + page_view: None, + screen_size: egui::Vec2::ZERO, page_title_input: String::new(), page_title_for: None, page_picker_list: false, @@ -314,6 +357,39 @@ impl UiState { self.file_picker = None; } + /// Blendet das Lineal ein (waagerecht in der Mitte des sichtbaren Blattbereichs) oder aus. + pub fn toggle_ruler(&mut self) { + self.ruler_drag = None; + self.ruler = match self.ruler { + Some(_) => None, + None => { + let center = self + .page_view + .map(|v| { + let screen = self.visible_canvas_center(); + v.to_page(screen) + }) + .unwrap_or(glam::Vec2::new( + crate::document::PAGE_WIDTH / 2.0, + crate::document::PAGE_HEIGHT / 2.0, + )); + Some(Ruler::new(center)) + } + }; + } + + /// Mitte des Bereichs neben der Werkzeugleiste (logische Punkte). + fn visible_canvas_center(&self) -> egui::Pos2 { + (self.screen_size / 2.0).to_pos2() + + egui::vec2( + match self.sidebar_side { + SidebarSide::Left => self.sidebar_reserved_width() / 2.0, + SidebarSide::Right => -self.sidebar_reserved_width() / 2.0, + }, + 0.0, + ) + } + /// Öffnet die Dateiauswahl „Bild einfügen“ im zuletzt benutzten Ordner. pub fn open_image_picker(&mut self) { self.open_file_picker(PickerKind::Image); @@ -442,6 +518,15 @@ impl UiState { if self.is_point_inside_toolbar(px, py) { return true; } + // Lineal: Fläche und Dreh-Griff bedient die Oberfläche, die Kanten bleiben zum Zeichnen + if self.ruler_drag.is_some() { + return true; + } + if let (Some(ruler), Some(view)) = (self.ruler, self.page_view) { + if ruler.hit(view.to_page(egui::pos2(px, py))).is_some() { + return true; + } + } if let Some(pop_rect) = self.popover_rect { if pop_rect.contains(egui::pos2(px, py)) { return true; @@ -554,6 +639,8 @@ enum SidebarIcon<'a> { Eraser, /// Selbst gezeichnetes gestricheltes Auswahlrechteck Lasso, + /// Selbst gezeichnetes Lineal mit Skala + Ruler, } /// Quadratischer Symbol-Knopf der Werkzeugleiste mit optionalem Farbbalken an der Unterkante. @@ -656,6 +743,30 @@ fn sidebar_button( stroke, )); } + SidebarIcon::Ruler => { + let dir = egui::vec2(1.0, -1.0).normalized(); + let perp = egui::vec2(dir.y, -dir.x); + let (half_len, half_w) = (12.0, 4.5); + let p = |along: f32, across: f32| c + dir * along + perp * across; + painter.add(egui::Shape::convex_polygon( + vec![ + p(-half_len, -half_w), + p(half_len, -half_w), + p(half_len, half_w), + p(-half_len, half_w), + ], + egui::Color32::TRANSPARENT, + egui::Stroke::new(1.5_f32, color), + )); + for i in 1..6 { + let along = -half_len + i as f32 * 4.0; + let tick = if i % 2 == 0 { 4.0 } else { 2.5 }; + painter.line_segment( + [p(along, -half_w), p(along, -half_w + tick)], + egui::Stroke::new(1.2_f32, color), + ); + } + } SidebarIcon::Lasso => { let r = egui::Rect::from_center_size(c, egui::vec2(20.0, 16.0)); let pts = [ @@ -1076,6 +1187,149 @@ fn pressure_curve_editor(ui: &mut egui::Ui, state: &mut UiState) -> Option Some(RulerDrag::Move { + grab_offset: ruler.center - page, + }), + Some(RulerHit::RotateHandle) => Some(RulerDrag::Rotate), + None => None, + }; + } + } + match (state.ruler_drag, down, pos) { + (Some(RulerDrag::Move { grab_offset }), true, Some(pos)) => { + ruler.center = view.to_page(pos) + grab_offset; + } + (Some(RulerDrag::Rotate), true, Some(pos)) => ruler.rotate_toward(view.to_page(pos)), + (_, false, _) => state.ruler_drag = None, + _ => {} + } + state.ruler = Some(ruler); + + // Zeichnen (unter Leiste und Dialogen) + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("ruler_overlay"), + )); + let corners: Vec = ruler.corners().iter().map(|&c| view.to_screen(c)).collect(); + painter.add(egui::Shape::convex_polygon( + corners.clone(), + egui::Color32::from_rgba_unmultiplied(214, 222, 235, 165), + egui::Stroke::new( + 1.0_f32, + egui::Color32::from_rgba_unmultiplied(60, 70, 90, 200), + ), + )); + + let ink = egui::Color32::from_rgb(45, 52, 66); + let axis = ruler.axis(); + let normal = ruler.normal(); + let mm = crate::document::UNITS_PER_MM; + let px_per_mm = view.scale * mm; + let half_len_mm = (crate::ruler::RULER_LENGTH / mm / 2.0) as i32; + let half_w = crate::ruler::RULER_WIDTH / 2.0; + // Skala an beiden Längskanten, von der Mitte aus: Millimeter nur, wenn sie nicht verschwimmen + for side in [-1.0_f32, 1.0] { + let edge = ruler.center + normal * side * half_w; + for i in -half_len_mm..=half_len_mm { + let tick_mm = if i % 10 == 0 { + 4.0 + } else if i % 5 == 0 { + 2.5 + } else if px_per_mm >= 3.0 { + 1.5 + } else { + continue; + }; + let base = edge + axis * (i as f32 * mm); + let tip = base - normal * side * tick_mm * mm; + painter.line_segment( + [view.to_screen(base), view.to_screen(tip)], + egui::Stroke::new(1.0_f32, ink), + ); + } + } + // Zentimeter-Zahlen an der oberen Kante, von der linken Ecke an gezählt + if px_per_mm >= 1.2 { + let font = egui::FontId::proportional((px_per_mm * 2.6).clamp(8.0, 13.0)); + let edge = ruler.center - normal * half_w; + for cm in 0..=(half_len_mm * 2 / 10) { + let along = -crate::ruler::RULER_LENGTH / 2.0 + cm as f32 * 10.0 * mm; + let anchor = view.to_screen(edge + axis * along + normal * 5.0 * mm); + let galley = ctx.fonts(|f| f.layout_no_wrap(cm.to_string(), font.clone(), ink)); + let offset = egui::vec2(galley.size().x / 2.0, 0.0); + let offset = egui::vec2( + offset.x * axis.x - offset.y * axis.y, + offset.x * axis.y + offset.y * axis.x, + ); + let mut text = egui::epaint::TextShape::new(anchor - offset, galley, ink); + text.angle = ruler.angle; + painter.add(text); + } + } + + // Winkelanzeige und Dreh-Griff + let center = view.to_screen(ruler.center); + let label = format!("{}°", ruler.display_degrees()); + let galley = ctx.fonts(|f| { + f.layout_no_wrap( + label, + egui::FontId::proportional(13.0), + egui::Color32::WHITE, + ) + }); + let pill = egui::Rect::from_center_size(center, galley.size() + egui::vec2(14.0, 6.0)); + painter.rect_filled( + pill, + egui::Rounding::same(9.0), + egui::Color32::from_rgba_unmultiplied(40, 70, 130, 220), + ); + painter.galley( + pill.center() - galley.size() / 2.0, + galley, + egui::Color32::WHITE, + ); + + let handle = view.to_screen(ruler.handle_center()); + let radius = (crate::ruler::HANDLE_RADIUS * view.scale).max(10.0); + let active = state.ruler_drag == Some(RulerDrag::Rotate); + painter.circle( + handle, + radius, + if active { + egui::Color32::from_rgb(70, 120, 210) + } else { + egui::Color32::from_rgba_unmultiplied(40, 70, 130, 200) + }, + egui::Stroke::new(1.0_f32, egui::Color32::WHITE), + ); + painter.text( + handle, + egui::Align2::CENTER_CENTER, + "⟳", + egui::FontId::proportional((radius * 1.1).clamp(10.0, 20.0)), + egui::Color32::WHITE, + ); +} + /// Dialog „Bild einfügen“ bzw. „PDF importieren“: Schnellzugriffe, Ordnerpfad und Liste aus /// Ordnern und passenden Dateien. fn file_picker_dialog(ctx: &egui::Context, state: &mut UiState, actions: &mut Vec) { @@ -1333,6 +1587,8 @@ pub fn render_ui( updater: &crate::updater::UpdaterController, ) -> Vec { let mut actions = std::mem::take(&mut state.queued_actions); + state.screen_size = ctx.screen_rect().size(); + ruler_overlay(ctx, state); let mut pen_btn_rect = None; let mut high_btn_rect = None; @@ -1519,6 +1775,22 @@ pub fn render_ui( } } + // --- Lineal (ein-/ausblenden) --- + if sidebar_button( + ui, + btn, + SidebarIcon::Ruler, + state.ruler.is_some(), + true, + None, + "Lineal (L): Fläche ziehen = verschieben, Griff ⟳ = drehen; \ + Striche an einer Kante werden gerade", + ) + .clicked() + { + state.toggle_ruler(); + } + // --- Farben des aktiven Werkzeugs --- let palette: Option<(&[ColorPreset], [f32; 4])> = match state.active_tool { @@ -3516,6 +3788,74 @@ mod tests { out } + #[test] + fn test_ruler_drag_moves_and_rotates() { + let ctx = egui::Context::default(); + let notebook = Notebook::new(); + let updater = crate::updater::UpdaterController::new(); + let mut state = UiState::new(); + // 1 Seiteneinheit = 0,5 Punkte, Seite beginnt bei (100, 0) + let view = PageView { + origin: egui::pos2(100.0, 0.0), + scale: 0.5, + }; + state.page_view = Some(view); + state.ruler = Some(Ruler::new(glam::Vec2::new(1000.0, 1000.0))); + + let frame = |state: &mut UiState, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1600.0, 1000.0), + )), + events, + ..Default::default() + }; + ctx.begin_frame(input); + let _ = render_ui(&ctx, state, ¬ebook, AutosaveStatus::Saved, &updater); + let _ = ctx.end_frame(); + }; + let press = |pos: egui::Pos2, pressed: bool| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + }; + + // Fläche und Griff gehören zur Oberfläche, die Kanten zum Zeichnen + let center = view.to_screen(glam::Vec2::new(1000.0, 1000.0)); + assert!(state.is_point_in_ui(center.x, center.y)); + let edge = view.to_screen(glam::Vec2::new( + 1000.0, + 1000.0 - crate::ruler::RULER_WIDTH / 2.0, + )); + assert!(!state.is_point_in_ui(edge.x, edge.y)); + + // Verschieben: Fläche greifen und um 50 Punkte (= 100 Einheiten) nach unten ziehen + frame(&mut state, vec![egui::Event::PointerMoved(center)]); + frame(&mut state, vec![press(center, true)]); + let target = center + egui::vec2(0.0, 50.0); + frame(&mut state, vec![egui::Event::PointerMoved(target)]); + assert!(state.ruler_drag.is_some()); + frame(&mut state, vec![press(target, false)]); + let moved = state.ruler.unwrap(); + assert!((moved.center - glam::Vec2::new(1000.0, 1100.0)).length() < 0.5); + assert!(state.ruler_drag.is_none()); + + // Drehen: Griff greifen und nach unten rechts ziehen → 45° (eingerastet) + let handle = view.to_screen(moved.handle_center()); + frame(&mut state, vec![egui::Event::PointerMoved(handle)]); + frame(&mut state, vec![press(handle, true)]); + let diag = view.to_screen(moved.center + glam::Vec2::new(700.0, 690.0)); + frame(&mut state, vec![egui::Event::PointerMoved(diag)]); + frame(&mut state, vec![press(diag, false)]); + assert_eq!(state.ruler.unwrap().display_degrees(), 45); + + // Ausblenden + state.toggle_ruler(); + assert!(state.ruler.is_none()); + } + #[test] fn test_stroke_tool_follows_active_tool() { assert_eq!(ActiveTool::Pen.stroke_tool(false), StrokeTool::Pen);