feat: virtuelles Lineal
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<crate::ruler::Ruler>,
|
||||
/// Kante, an der der aktuelle Live-Strich eingerastet ist
|
||||
ruler_lock: Option<crate::ruler::EdgeLock>,
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+19
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
|
||||
// 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;
|
||||
|
||||
+239
@@ -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<RulerHit> {
|
||||
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<EdgeLock> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<f32>,
|
||||
/// Geöffnete Dateiauswahl („Bild einfügen“ oder „PDF importieren“)
|
||||
pub file_picker: Option<FilePicker>,
|
||||
/// Eingeblendetes Lineal
|
||||
pub ruler: Option<Ruler>,
|
||||
pub ruler_drag: Option<RulerDrag>,
|
||||
/// Aktuelle Abbildung Seite ↔ Bildschirm (für Lineal und Treffertests)
|
||||
pub page_view: Option<PageView>,
|
||||
/// 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<u64>,
|
||||
@@ -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<Press
|
||||
save.then_some(state.pressure_curve)
|
||||
}
|
||||
|
||||
/// Zeichnet das Lineal über dem Blatt und bedient Verschieben und Drehen.
|
||||
fn ruler_overlay(ctx: &egui::Context, state: &mut UiState) {
|
||||
let (Some(mut ruler), Some(view)) = (state.ruler, state.page_view) else {
|
||||
state.ruler_drag = None;
|
||||
return;
|
||||
};
|
||||
|
||||
// Bedienung: Druck auf Fläche oder Griff beginnt das Ziehen (nicht über Leiste/Popovern)
|
||||
let (pressed, down, pos) = ctx.input(|i| {
|
||||
(
|
||||
i.pointer.primary_pressed(),
|
||||
i.pointer.primary_down(),
|
||||
i.pointer.interact_pos(),
|
||||
)
|
||||
});
|
||||
if pressed && !ctx.is_pointer_over_area() {
|
||||
if let Some(pos) = pos {
|
||||
let page = view.to_page(pos);
|
||||
state.ruler_drag = match ruler.hit(page) {
|
||||
Some(RulerHit::Body) => 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<egui::Pos2> = 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<UiAction>) {
|
||||
@@ -1333,6 +1587,8 @@ pub fn render_ui(
|
||||
updater: &crate::updater::UpdaterController,
|
||||
) -> Vec<UiAction> {
|
||||
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<egui::Event>| {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user