Skip to main content

draw_core/
geometry.rs

1//! Shared geometry helpers used by both the pixel renderer and SVG export.
2
3/// Arrowhead geometry constants.
4pub(crate) const ARROWHEAD_LENGTH: f64 = 14.0;
5pub(crate) const ARROWHEAD_ANGLE: f64 = 0.45;
6
7/// Hachure fill line width.
8pub(crate) const HACHURE_LINE_WIDTH: f64 = 1.5;
9
10/// Normalize a bounding box that may have negative width/height.
11/// Returns `(x, y, abs_width, abs_height)` with the top-left corner.
12pub(crate) fn normalize_bounds(x: f64, y: f64, w: f64, h: f64) -> (f64, f64, f64, f64) {
13    let nx = if w < 0.0 { x + w } else { x };
14    let ny = if h < 0.0 { y + h } else { y };
15    (nx, ny, w.abs(), h.abs())
16}
17
18/// The three vertices of an arrowhead triangle.
19pub(crate) struct ArrowheadPoints {
20    pub(crate) tip_x: f64,
21    pub(crate) tip_y: f64,
22    pub(crate) left_x: f64,
23    pub(crate) left_y: f64,
24    pub(crate) right_x: f64,
25    pub(crate) right_y: f64,
26}
27
28/// Compute arrowhead triangle vertices.
29///
30/// `tip` is the point of the arrow, `from` is the point it points away from.
31/// `length` and `spread` control the size and opening angle.
32pub(crate) fn compute_arrowhead(
33    tip_x: f64,
34    tip_y: f64,
35    from_x: f64,
36    from_y: f64,
37    length: f64,
38    spread: f64,
39) -> ArrowheadPoints {
40    let angle = (tip_y - from_y).atan2(tip_x - from_x);
41    ArrowheadPoints {
42        tip_x,
43        tip_y,
44        left_x: tip_x - length * (angle - spread).cos(),
45        left_y: tip_y - length * (angle - spread).sin(),
46        right_x: tip_x - length * (angle + spread).cos(),
47        right_y: tip_y - length * (angle + spread).sin(),
48    }
49}
50
51/// A single hachure line segment.
52pub(crate) struct HachureLine {
53    pub(crate) x1: f64,
54    pub(crate) y1: f64,
55    pub(crate) x2: f64,
56    pub(crate) y2: f64,
57}
58
59/// Generate parallel hachure lines rotated around the center of a bounding box.
60///
61/// Lines span from `-diag` to `+diag` so they fully cover the shape before
62/// clipping. `gap` is the spacing between lines and `angle` is the rotation
63/// in radians.
64pub(crate) fn generate_hachure_lines(
65    cx: f64,
66    cy: f64,
67    width: f64,
68    height: f64,
69    gap: f64,
70    angle: f64,
71) -> Vec<HachureLine> {
72    let diag = (width * width + height * height).sqrt();
73    let cos_a = angle.cos();
74    let sin_a = angle.sin();
75
76    let mut lines = Vec::new();
77    let mut d = -diag;
78    while d < diag {
79        lines.push(HachureLine {
80            x1: cx + d * cos_a - (-diag) * sin_a,
81            y1: cy + d * sin_a + (-diag) * cos_a,
82            x2: cx + d * cos_a - diag * sin_a,
83            y2: cy + d * sin_a + diag * cos_a,
84        });
85        d += gap;
86    }
87    lines
88}
89
90use crate::element::Element;
91
92/// Connection point on a shape where arrows can snap.
93#[derive(Debug, Clone)]
94pub struct ConnectionPoint {
95    pub x: f64,
96    pub y: f64,
97}
98
99/// Compute the 8 connection points for a shape element.
100/// Returns 4 edge midpoints + 4 corners (or shape-specific points).
101/// Returns empty vec for non-shape elements (Line, Arrow, FreeDraw, Text).
102pub fn connection_points(el: &Element) -> Vec<ConnectionPoint> {
103    match el {
104        Element::Rectangle(e) => {
105            let (x, y, w, h) = normalize_bounds(e.x, e.y, e.width, e.height);
106            rectangle_connection_points(x, y, w, h)
107        }
108        Element::Ellipse(e) => {
109            let (x, y, w, h) = normalize_bounds(e.x, e.y, e.width, e.height);
110            ellipse_connection_points(x, y, w, h)
111        }
112        Element::Diamond(e) => {
113            let (x, y, w, h) = normalize_bounds(e.x, e.y, e.width, e.height);
114            diamond_connection_points(x, y, w, h)
115        }
116        _ => Vec::new(),
117    }
118}
119
120fn rectangle_connection_points(x: f64, y: f64, w: f64, h: f64) -> Vec<ConnectionPoint> {
121    vec![
122        // Edge midpoints
123        ConnectionPoint { x: x + w / 2.0, y }, // top center
124        ConnectionPoint {
125            x: x + w,
126            y: y + h / 2.0,
127        }, // right center
128        ConnectionPoint {
129            x: x + w / 2.0,
130            y: y + h,
131        }, // bottom center
132        ConnectionPoint { x, y: y + h / 2.0 }, // left center
133        // Corners
134        ConnectionPoint { x, y },               // top-left
135        ConnectionPoint { x: x + w, y },        // top-right
136        ConnectionPoint { x: x + w, y: y + h }, // bottom-right
137        ConnectionPoint { x, y: y + h },        // bottom-left
138    ]
139}
140
141fn ellipse_connection_points(x: f64, y: f64, w: f64, h: f64) -> Vec<ConnectionPoint> {
142    let cx = x + w / 2.0;
143    let cy = y + h / 2.0;
144    let rx = w / 2.0;
145    let ry = h / 2.0;
146    let cos45 = std::f64::consts::FRAC_PI_4.cos();
147    let sin45 = std::f64::consts::FRAC_PI_4.sin();
148    vec![
149        // Cardinal points (edge midpoints of bounding box on ellipse perimeter)
150        ConnectionPoint { x: cx, y: cy - ry }, // top
151        ConnectionPoint { x: cx + rx, y: cy }, // right
152        ConnectionPoint { x: cx, y: cy + ry }, // bottom
153        ConnectionPoint { x: cx - rx, y: cy }, // left
154        // 45-degree points on ellipse perimeter
155        ConnectionPoint {
156            x: cx + rx * cos45,
157            y: cy - ry * sin45,
158        }, // top-right
159        ConnectionPoint {
160            x: cx + rx * cos45,
161            y: cy + ry * sin45,
162        }, // bottom-right
163        ConnectionPoint {
164            x: cx - rx * cos45,
165            y: cy + ry * sin45,
166        }, // bottom-left
167        ConnectionPoint {
168            x: cx - rx * cos45,
169            y: cy - ry * sin45,
170        }, // top-left
171    ]
172}
173
174fn diamond_connection_points(x: f64, y: f64, w: f64, h: f64) -> Vec<ConnectionPoint> {
175    let cx = x + w / 2.0;
176    let cy = y + h / 2.0;
177    vec![
178        // Vertices (the 4 tips of the diamond)
179        ConnectionPoint { x: cx, y },        // top vertex
180        ConnectionPoint { x: x + w, y: cy }, // right vertex
181        ConnectionPoint { x: cx, y: y + h }, // bottom vertex
182        ConnectionPoint { x, y: cy },        // left vertex
183        // Edge midpoints (between adjacent vertices)
184        ConnectionPoint {
185            x: cx + w / 4.0,
186            y: y + h / 4.0,
187        }, // top-right edge mid
188        ConnectionPoint {
189            x: cx + w / 4.0,
190            y: cy + h / 4.0,
191        }, // bottom-right edge mid
192        ConnectionPoint {
193            x: cx - w / 4.0,
194            y: cy + h / 4.0,
195        }, // bottom-left edge mid
196        ConnectionPoint {
197            x: cx - w / 4.0,
198            y: y + h / 4.0,
199        }, // top-left edge mid
200    ]
201}
202
203/// Find the nearest connection point within `threshold` world-coordinate distance.
204/// `wx, wy` are the world coordinates to snap to.
205/// `exclude_id` is the element to skip (the arrow being drawn).
206/// Returns `(element_id, snap_x, snap_y)` or None.
207pub fn find_nearest_snap_point(
208    elements: &[Element],
209    wx: f64,
210    wy: f64,
211    threshold: f64,
212    exclude_id: &str,
213) -> Option<(String, f64, f64)> {
214    let mut best: Option<(String, f64, f64, f64)> = None; // (id, x, y, dist)
215
216    for el in elements {
217        if el.id() == exclude_id {
218            continue;
219        }
220        let pts = connection_points(el);
221        for cp in &pts {
222            let dist = ((cp.x - wx).powi(2) + (cp.y - wy).powi(2)).sqrt();
223            if dist < threshold && best.as_ref().is_none_or(|b| dist < b.3) {
224                best = Some((el.id().to_string(), cp.x, cp.y, dist));
225            }
226        }
227    }
228
229    best.map(|(id, x, y, _)| (id, x, y))
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn normalize_positive_bounds() {
238        let (x, y, w, h) = normalize_bounds(10.0, 20.0, 100.0, 50.0);
239        assert_eq!((x, y, w, h), (10.0, 20.0, 100.0, 50.0));
240    }
241
242    #[test]
243    fn normalize_negative_width() {
244        let (x, y, w, h) = normalize_bounds(110.0, 20.0, -100.0, 50.0);
245        assert_eq!((x, y, w, h), (10.0, 20.0, 100.0, 50.0));
246    }
247
248    #[test]
249    fn normalize_negative_both() {
250        let (x, y, w, h) = normalize_bounds(110.0, 70.0, -100.0, -50.0);
251        assert_eq!((x, y, w, h), (10.0, 20.0, 100.0, 50.0));
252    }
253
254    #[test]
255    fn arrowhead_horizontal_right() {
256        let pts = compute_arrowhead(100.0, 0.0, 0.0, 0.0, 14.0, 0.45);
257        assert!((pts.tip_x - 100.0).abs() < 1e-10);
258        assert!((pts.tip_y - 0.0).abs() < 1e-10);
259        // Left/right should be symmetric about the axis
260        assert!((pts.left_y + pts.right_y).abs() < 1e-10);
261    }
262
263    #[test]
264    fn hachure_lines_count() {
265        let lines = generate_hachure_lines(50.0, 50.0, 100.0, 100.0, 10.0, 0.0);
266        assert!(!lines.is_empty());
267        // With diag ~141.4 and gap 10, expect ~28 lines
268        assert!(lines.len() > 20);
269        assert!(lines.len() < 40);
270    }
271
272    #[test]
273    fn hachure_lines_empty_for_huge_gap() {
274        let lines = generate_hachure_lines(50.0, 50.0, 10.0, 10.0, 1000.0, 0.0);
275        // diag ~14.1, gap 1000 — only one or zero lines
276        assert!(lines.len() <= 1);
277    }
278
279    #[test]
280    fn rectangle_connection_points_count() {
281        use crate::element::{Element, ShapeElement};
282        let el = Element::Rectangle(ShapeElement::new("r1".into(), 0.0, 0.0, 100.0, 50.0));
283        let pts = connection_points(&el);
284        assert_eq!(pts.len(), 8);
285        // Top center
286        assert!((pts[0].x - 50.0).abs() < 1e-10);
287        assert!((pts[0].y - 0.0).abs() < 1e-10);
288    }
289
290    #[test]
291    fn ellipse_connection_points_count() {
292        use crate::element::{Element, ShapeElement};
293        let el = Element::Ellipse(ShapeElement::new("e1".into(), 0.0, 0.0, 100.0, 60.0));
294        let pts = connection_points(&el);
295        assert_eq!(pts.len(), 8);
296        // Top point should be at center-x, top of ellipse
297        assert!((pts[0].x - 50.0).abs() < 1e-10);
298        assert!((pts[0].y - 0.0).abs() < 1e-10);
299    }
300
301    #[test]
302    fn diamond_connection_points_count() {
303        use crate::element::{Element, ShapeElement};
304        let el = Element::Diamond(ShapeElement::new("d1".into(), 0.0, 0.0, 100.0, 80.0));
305        let pts = connection_points(&el);
306        assert_eq!(pts.len(), 8);
307        // Top vertex
308        assert!((pts[0].x - 50.0).abs() < 1e-10);
309        assert!((pts[0].y - 0.0).abs() < 1e-10);
310    }
311
312    #[test]
313    fn find_snap_point_within_threshold() {
314        use crate::element::{Element, ShapeElement};
315        let elements = vec![Element::Rectangle(ShapeElement::new(
316            "r1".into(),
317            100.0,
318            100.0,
319            80.0,
320            60.0,
321        ))];
322        // Top-center of rectangle is at (140, 100)
323        let result = find_nearest_snap_point(&elements, 142.0, 102.0, 15.0, "");
324        assert!(result.is_some());
325        let (id, sx, sy) = result.unwrap();
326        assert_eq!(id, "r1");
327        assert!((sx - 140.0).abs() < 1e-10);
328        assert!((sy - 100.0).abs() < 1e-10);
329    }
330
331    #[test]
332    fn find_snap_point_outside_threshold() {
333        use crate::element::{Element, ShapeElement};
334        let elements = vec![Element::Rectangle(ShapeElement::new(
335            "r1".into(),
336            100.0,
337            100.0,
338            80.0,
339            60.0,
340        ))];
341        // Far from any connection point
342        let result = find_nearest_snap_point(&elements, 0.0, 0.0, 15.0, "");
343        assert!(result.is_none());
344    }
345
346    #[test]
347    fn find_snap_excludes_self() {
348        use crate::element::{Element, ShapeElement};
349        let elements = vec![Element::Rectangle(ShapeElement::new(
350            "r1".into(),
351            100.0,
352            100.0,
353            80.0,
354            60.0,
355        ))];
356        // Within threshold but excluded
357        let result = find_nearest_snap_point(&elements, 140.0, 100.0, 15.0, "r1");
358        assert!(result.is_none());
359    }
360}