From f9ede537eddf28dad335a6007109fa0357719a63 Mon Sep 17 00:00:00 2001 From: Harald Date: Wed, 7 Oct 2026 10:14:06 +0200 Subject: [PATCH] fix(geometry): spitze Pfeilspitzen beim Koordinatensystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Achsenlinien liefen in voller Strichbreite bis zur Spitze und überdeckten sie; zudem war die Spitze klein und breit (~67° Öffnung). - Gemeinsame Pfeilspitzen-Geometrie (arrow_head) für Bildschirm und PDF: ~4 mm lang (dicke Linien länger, höchstens 1/5 der Achse), ~40° Öffnung - Achsen enden an der Basis der Spitze - PDF-Export nutzt dieselben Maße (vorher eigene, abweichende Werte) Co-Authored-By: Claude Opus 5.5 --- src/export.rs | 112 ++++++++++++++++--------------------- src/geometry.rs | 143 ++++++++++++++++++++++++++++++++---------------- 2 files changed, 144 insertions(+), 111 deletions(-) diff --git a/src/export.rs b/src/export.rs index 68aa202..3a1eaf9 100644 --- a/src/export.rs +++ b/src/export.rs @@ -412,76 +412,58 @@ fn render_recognized_shape( y_axis_end, with_arrows, } => { + // Gleiche Pfeilspitzen wie auf dem Bildschirm; Achsen enden an der Basis der Spitze + // (Linienstärke 1,5 pt ≈ 5,3 Seiteneinheiten, halbe Breite ≈ 2,65) + const PDF_SHAPE_HALF_WIDTH: f32 = 2.65; + let y_dir = (*y_axis_end - *origin).normalize_or_zero(); + let x_dir = (*x_axis_end - *origin).normalize_or_zero(); + let y_arrow = crate::geometry::arrow_head( + *y_axis_end, + y_dir, + PDF_SHAPE_HALF_WIDTH, + (*y_axis_end - *origin).length(), + ); + let x_arrow = crate::geometry::arrow_head( + *x_axis_end, + x_dir, + PDF_SHAPE_HALF_WIDTH, + (*x_axis_end - *origin).length(), + ); + let (y_line_end, x_line_end) = if *with_arrows { + (y_arrow.0, x_arrow.0) + } else { + (*y_axis_end, *x_axis_end) + }; + let (ox, oy) = page_to_pdf_mm(origin.x, origin.y); - let (xx, xy) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y); - let (yx, yy) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); - - // Achse 1 (Y) - let line_y = Line { - points: vec![ - (Point::new(Mm(ox), Mm(oy)), false), - (Point::new(Mm(yx), Mm(yy)), false), - ], - is_closed: false, - }; - layer.add_line(line_y); - - // Achse 2 (X) - let line_x = Line { - points: vec![ - (Point::new(Mm(ox), Mm(oy)), false), - (Point::new(Mm(xx), Mm(xy)), false), - ], - is_closed: false, - }; - layer.add_line(line_x); + for end in [y_line_end, x_line_end] { + let (ex, ey) = page_to_pdf_mm(end.x, end.y); + layer.add_line(Line { + points: vec![ + (Point::new(Mm(ox), Mm(oy)), false), + (Point::new(Mm(ex), Mm(ey)), false), + ], + is_closed: false, + }); + } // Pfeilspitzen als gefüllte Dreiecke if *with_arrows { layer.set_fill_color(stroke_color); - - let y_dir = (*y_axis_end - *origin).normalize_or_zero(); - let arrow_len_page = 25.0; - let arrow_w_page = 16.0; - - let base_y = *y_axis_end - y_dir * arrow_len_page; - let left_y = base_y + glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); - let right_y = base_y - glam::Vec2::new(-y_dir.y, y_dir.x) * (arrow_w_page * 0.5); - - let (tx, ty) = page_to_pdf_mm(y_axis_end.x, y_axis_end.y); - let (lx, ly) = page_to_pdf_mm(left_y.x, left_y.y); - let (rx, ry) = page_to_pdf_mm(right_y.x, right_y.y); - - let arrow_poly_y = Polygon { - rings: vec![vec![ - (Point::new(Mm(tx), Mm(ty)), false), - (Point::new(Mm(lx), Mm(ly)), false), - (Point::new(Mm(rx), Mm(ry)), false), - ]], - mode: PaintMode::Fill, - winding_order: WindingOrder::NonZero, - }; - layer.add_polygon(arrow_poly_y); - - let x_dir = (*x_axis_end - *origin).normalize_or_zero(); - let base_x = *x_axis_end - x_dir * arrow_len_page; - let left_x = base_x + glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5); - let right_x = base_x - glam::Vec2::new(-x_dir.y, x_dir.x) * (arrow_w_page * 0.5); - - let (tx_x, ty_x) = page_to_pdf_mm(x_axis_end.x, x_axis_end.y); - let (lx_x, ly_x) = page_to_pdf_mm(left_x.x, left_x.y); - let (rx_x, ry_x) = page_to_pdf_mm(right_x.x, right_x.y); - - let arrow_poly_x = Polygon { - rings: vec![vec![ - (Point::new(Mm(tx_x), Mm(ty_x)), false), - (Point::new(Mm(lx_x), Mm(ly_x)), false), - (Point::new(Mm(rx_x), Mm(ry_x)), false), - ]], - mode: PaintMode::Fill, - winding_order: WindingOrder::NonZero, - }; - layer.add_polygon(arrow_poly_x); + for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] { + let ring = [tip, left, right] + .iter() + .map(|p| { + let (px, py) = page_to_pdf_mm(p.x, p.y); + (Point::new(Mm(px), Mm(py)), false) + }) + .collect(); + layer.add_polygon(Polygon { + rings: vec![ring], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }); + } } } } diff --git a/src/geometry.rs b/src/geometry.rs index 4757964..d95929b 100644 --- a/src/geometry.rs +++ b/src/geometry.rs @@ -1269,6 +1269,23 @@ fn best_closed_shape(pts: &[Vec2]) -> Option<(f32, RecognizedShape)> { .min_by(|a, b| a.0.total_cmp(&b.0)) } +/// Halber Öffnungswinkel einer Pfeilspitze als Verhältnis halbe Breite zu Länge (tan 20° ≈ 0,36, +/// also ~40° Öffnung – schlank genug, dass die Spitze spitz wirkt). +const ARROW_HALF_WIDTH_RATIO: f32 = 0.36; + +/// Geometrie einer Pfeilspitze an `tip`, Richtung `dir` (Einheitsvektor), für eine Linie der +/// halben Breite `half_w` (Seiteneinheiten) und der Länge `line_len`. Liefert (Basismitte, +/// linke Ecke, rechte Ecke). Die Spitze ist ~4 mm lang (dicke Linien länger), höchstens ein +/// Fünftel der Linie. Die Linie soll an der Basismitte enden – reicht sie bis zur Spitze, +/// wirkt diese stumpf. +pub fn arrow_head(tip: Vec2, dir: Vec2, half_w: f32, line_len: f32) -> (Vec2, Vec2, Vec2) { + let len = (half_w * 10.0).max(40.0).min(line_len * 0.2); + let half_width = len * ARROW_HALF_WIDTH_RATIO; + let base = tip - dir * len; + let normal = Vec2::new(-dir.y, dir.x); + (base, base + normal * half_width, base - normal * half_width) +} + /// Abstand eines Punktes zur Strecke `a`–`b`. fn segment_distance(p: Vec2, a: Vec2, b: Vec2) -> f32 { let ab = b - a; @@ -1539,11 +1556,20 @@ pub fn shape_to_mesh( let mut vertices = Vec::new(); let mut samples = Vec::new(); - // 1. Y-Achse (von Pfeilspitze oben zum Ursprung) + // Achsen enden bei Pfeilen an der Basis der Spitze, sonst überdeckt das Linienband + // die Spitze und sie wirkt stumpf let y_dir = (*y_axis_end - *origin).normalize_or_zero(); - let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w; + let x_dir = (*x_axis_end - *origin).normalize_or_zero(); + let y_arrow = arrow_head(*y_axis_end, y_dir, half_w, (*y_axis_end - *origin).length()); + let x_arrow = arrow_head(*x_axis_end, x_dir, half_w, (*x_axis_end - *origin).length()); + let (y_line_end, x_line_end) = if *with_arrows { + (y_arrow.0, x_arrow.0) + } else { + (*y_axis_end, *x_axis_end) + }; - // Y-Linienband + // 1. Y-Achse (vom Ursprung zur Spitze) + let y_norm = Vec2::new(-y_dir.y, y_dir.x) * half_w; vertices.push(InkVertex::new( origin.x + y_norm.x, origin.y + y_norm.y, @@ -1555,26 +1581,24 @@ pub fn shape_to_mesh( color, )); vertices.push(InkVertex::new( - y_axis_end.x + y_norm.x, - y_axis_end.y + y_norm.y, + y_line_end.x + y_norm.x, + y_line_end.y + y_norm.y, color, )); vertices.push(InkVertex::new( - y_axis_end.x - y_norm.x, - y_axis_end.y - y_norm.y, + y_line_end.x - y_norm.x, + y_line_end.y - y_norm.y, color, )); - // Degenerate Dreiecke für Übergang zur X-Achse - let x_dir = (*x_axis_end - *origin).normalize_or_zero(); + // Degenerierte Dreiecke für den Übergang zur X-Achse let x_norm = Vec2::new(-x_dir.y, x_dir.x) * half_w; - let last_v = *vertices.last().unwrap(); let next_v = InkVertex::new(origin.x + x_norm.x, origin.y + x_norm.y, color); vertices.push(last_v); vertices.push(next_v); - // 2. X-Linienband + // 2. X-Achse vertices.push(next_v); vertices.push(InkVertex::new( origin.x - x_norm.x, @@ -1582,48 +1606,27 @@ pub fn shape_to_mesh( color, )); vertices.push(InkVertex::new( - x_axis_end.x + x_norm.x, - x_axis_end.y + x_norm.y, + x_line_end.x + x_norm.x, + x_line_end.y + x_norm.y, color, )); vertices.push(InkVertex::new( - x_axis_end.x - x_norm.x, - x_axis_end.y - x_norm.y, + x_line_end.x - x_norm.x, + x_line_end.y - x_norm.y, color, )); - // 3. Optionale Pfeilspitzen + // 3. Optionale Pfeilspitzen (je ein Dreieck, über degenerierte Dreiecke angehängt) if *with_arrows { - let arrow_len = (half_w * 5.0).max(18.0); - let arrow_w = (half_w * 3.5).max(12.0); - - // Pfeilspitze Y - let y_base = *y_axis_end - y_dir * arrow_len; - let y_left = y_base + Vec2::new(-y_dir.y, y_dir.x) * arrow_w; - let y_right = y_base - Vec2::new(-y_dir.y, y_dir.x) * arrow_w; - - let last_v = *vertices.last().unwrap(); - let tip_v = InkVertex::new(y_axis_end.x, y_axis_end.y, color); - vertices.push(last_v); - vertices.push(tip_v); - - vertices.push(tip_v); - vertices.push(InkVertex::new(y_left.x, y_left.y, color)); - vertices.push(InkVertex::new(y_right.x, y_right.y, color)); - - // Pfeilspitze X - let x_base = *x_axis_end - x_dir * arrow_len; - let x_left = x_base + Vec2::new(-x_dir.y, x_dir.x) * arrow_w; - let x_right = x_base - Vec2::new(-x_dir.y, x_dir.x) * arrow_w; - - let last_v = *vertices.last().unwrap(); - let tip_x = InkVertex::new(x_axis_end.x, x_axis_end.y, color); - vertices.push(last_v); - vertices.push(tip_x); - - vertices.push(tip_x); - vertices.push(InkVertex::new(x_left.x, x_left.y, color)); - vertices.push(InkVertex::new(x_right.x, x_right.y, color)); + for (tip, (_, left, right)) in [(*y_axis_end, y_arrow), (*x_axis_end, x_arrow)] { + let last_v = *vertices.last().unwrap(); + let tip_v = InkVertex::new(tip.x, tip.y, color); + vertices.push(last_v); + vertices.push(tip_v); + vertices.push(tip_v); + vertices.push(InkVertex::new(left.x, left.y, color)); + vertices.push(InkVertex::new(right.x, right.y, color)); + } } // Abtastpunkte @@ -2400,4 +2403,52 @@ mod tests { }) .collect() } + + #[test] + fn test_arrow_head_is_slender_and_axes_end_at_its_base() { + // Spitze schlank (~40° Öffnung) und breiter als die Linie + let (base, left, right) = arrow_head(Vec2::new(0.0, 0.0), Vec2::new(0.0, -1.0), 2.5, 500.0); + let len = base.length(); + let half_width = (left - base).length(); + let opening = 2.0 * (half_width / len).atan().to_degrees(); + assert!( + (35.0..=45.0).contains(&opening), + "Öffnungswinkel {opening}°" + ); + assert!( + half_width > 2.5 * 2.0, + "Spitze muss breiter als die Linie sein" + ); + assert!(((left + right) * 0.5 - base).length() < 1e-4); + assert!(len >= 40.0, "Spitze mindestens 4 mm lang: {len}"); + // Bei kurzen Linien höchstens ein Fünftel der Linie + let (short_base, ..) = arrow_head(Vec2::ZERO, Vec2::new(1.0, 0.0), 2.5, 100.0); + assert!((short_base.length() - 20.0).abs() < 1e-3); + + // Kein Vertex des Linienbands ragt über die Basis hinaus in die Spitze hinein + let origin = Vec2::new(100.0, 300.0); + let y_end = Vec2::new(100.0, 100.0); + let x_end = Vec2::new(300.0, 300.0); + let shape = RecognizedShape::CoordinateSystem { + origin, + x_axis_end: x_end, + y_axis_end: y_end, + with_arrows: true, + }; + let (_, vertices) = shape_to_mesh(&shape, 5.0, [0.0, 0.0, 0.0, 1.0]); + let (y_base, ..) = arrow_head(y_end, Vec2::new(0.0, -1.0), 2.5, 200.0); + let (x_base, ..) = arrow_head(x_end, Vec2::new(1.0, 0.0), 2.5, 200.0); + for v in &vertices { + let p = Vec2::new(v.position[0], v.position[1]); + let is_tip = (p - y_end).length() < 1e-3 || (p - x_end).length() < 1e-3; + assert!( + is_tip || p.y >= y_base.y - 1e-3, + "Y-Achse ragt in die Spitze: {p:?}" + ); + assert!( + is_tip || p.x <= x_base.x + 1e-3, + "X-Achse ragt in die Spitze: {p:?}" + ); + } + } }