Skip to main content

batuta/oracle/svg/
shapes.rs

1//! SVG Shape Primitives
2//!
3//! Basic shapes for building diagrams: rectangles, circles, paths, text.
4
5use super::palette::Color;
6use super::typography::TextStyle;
7
8/// Point in 2D space
9#[derive(Debug, Clone, Copy, PartialEq, Default)]
10pub struct Point {
11    pub x: f32,
12    pub y: f32,
13}
14
15impl Point {
16    pub const fn new(x: f32, y: f32) -> Self {
17        Self { x, y }
18    }
19
20    /// Distance to another point
21    pub fn distance(&self, other: &Point) -> f32 {
22        ((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt()
23    }
24
25    /// Midpoint between two points
26    pub fn midpoint(&self, other: &Point) -> Point {
27        Point::new(f32::midpoint(self.x, other.x), f32::midpoint(self.y, other.y))
28    }
29}
30
31/// Size (width and height)
32#[derive(Debug, Clone, Copy, PartialEq, Default)]
33pub struct Size {
34    pub width: f32,
35    pub height: f32,
36}
37
38impl Size {
39    pub const fn new(width: f32, height: f32) -> Self {
40        Self { width, height }
41    }
42
43    /// Area
44    pub fn area(&self) -> f32 {
45        self.width * self.height
46    }
47}
48
49/// A rectangle
50#[derive(Debug, Clone, PartialEq)]
51pub struct Rect {
52    /// Top-left corner position
53    pub position: Point,
54    /// Size
55    pub size: Size,
56    /// Corner radius (0 for sharp corners)
57    pub corner_radius: f32,
58    /// Fill color
59    pub fill: Option<Color>,
60    /// Stroke color
61    pub stroke: Option<Color>,
62    /// Stroke width
63    pub stroke_width: f32,
64}
65
66impl Rect {
67    /// Create a new rectangle
68    pub fn new(x: f32, y: f32, width: f32, height: f32) -> Self {
69        Self {
70            position: Point::new(x, y),
71            size: Size::new(width, height),
72            corner_radius: 0.0,
73            fill: None,
74            stroke: None,
75            stroke_width: 1.0,
76        }
77    }
78
79    /// Set corner radius
80    pub fn with_radius(mut self, radius: f32) -> Self {
81        self.corner_radius = radius;
82        self
83    }
84
85    /// Set fill color
86    pub fn with_fill(mut self, color: Color) -> Self {
87        self.fill = Some(color);
88        self
89    }
90
91    /// Set stroke
92    pub fn with_stroke(mut self, color: Color, width: f32) -> Self {
93        self.stroke = Some(color);
94        self.stroke_width = width;
95        self
96    }
97
98    /// Get center point
99    pub fn center(&self) -> Point {
100        Point::new(
101            self.position.x + self.size.width / 2.0,
102            self.position.y + self.size.height / 2.0,
103        )
104    }
105
106    /// Get right edge x coordinate
107    pub fn right(&self) -> f32 {
108        self.position.x + self.size.width
109    }
110
111    /// Get bottom edge y coordinate
112    pub fn bottom(&self) -> f32 {
113        self.position.y + self.size.height
114    }
115
116    /// Check if a point is inside the rectangle
117    pub fn contains(&self, point: &Point) -> bool {
118        point.x >= self.position.x
119            && point.x <= self.right()
120            && point.y >= self.position.y
121            && point.y <= self.bottom()
122    }
123
124    /// Check if two rectangles overlap
125    pub fn intersects(&self, other: &Rect) -> bool {
126        self.position.x < other.right()
127            && self.right() > other.position.x
128            && self.position.y < other.bottom()
129            && self.bottom() > other.position.y
130    }
131
132    /// Render to SVG element
133    pub fn to_svg(&self) -> String {
134        let mut attrs = format!(
135            "x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"",
136            self.position.x, self.position.y, self.size.width, self.size.height
137        );
138
139        if self.corner_radius > 0.0 {
140            attrs.push_str(&format!(" rx=\"{}\"", self.corner_radius));
141        }
142
143        if let Some(fill) = &self.fill {
144            attrs.push_str(&format!(" fill=\"{}\"", fill.to_css_hex()));
145        } else {
146            attrs.push_str(" fill=\"none\"");
147        }
148
149        if let Some(stroke) = &self.stroke {
150            attrs.push_str(&format!(
151                " stroke=\"{}\" stroke-width=\"{}\"",
152                stroke.to_css_hex(),
153                self.stroke_width
154            ));
155        }
156
157        format!("<rect {}/>", attrs)
158    }
159}
160
161impl Default for Rect {
162    fn default() -> Self {
163        Self::new(0.0, 0.0, 100.0, 100.0)
164    }
165}
166
167/// A circle
168#[derive(Debug, Clone, PartialEq)]
169pub struct Circle {
170    /// Center position
171    pub center: Point,
172    /// Radius
173    pub radius: f32,
174    /// Fill color
175    pub fill: Option<Color>,
176    /// Stroke color
177    pub stroke: Option<Color>,
178    /// Stroke width
179    pub stroke_width: f32,
180}
181
182impl Circle {
183    /// Create a new circle
184    pub fn new(cx: f32, cy: f32, r: f32) -> Self {
185        Self { center: Point::new(cx, cy), radius: r, fill: None, stroke: None, stroke_width: 1.0 }
186    }
187
188    /// Set fill color
189    pub fn with_fill(mut self, color: Color) -> Self {
190        self.fill = Some(color);
191        self
192    }
193
194    /// Set stroke
195    pub fn with_stroke(mut self, color: Color, width: f32) -> Self {
196        self.stroke = Some(color);
197        self.stroke_width = width;
198        self
199    }
200
201    /// Get bounding rectangle
202    pub fn bounds(&self) -> Rect {
203        Rect::new(
204            self.center.x - self.radius,
205            self.center.y - self.radius,
206            self.radius * 2.0,
207            self.radius * 2.0,
208        )
209    }
210
211    /// Check if a point is inside the circle
212    pub fn contains(&self, point: &Point) -> bool {
213        self.center.distance(point) <= self.radius
214    }
215
216    /// Check if two circles overlap
217    pub fn intersects(&self, other: &Circle) -> bool {
218        self.center.distance(&other.center) < self.radius + other.radius
219    }
220
221    /// Render to SVG element
222    pub fn to_svg(&self) -> String {
223        let mut attrs =
224            format!("cx=\"{}\" cy=\"{}\" r=\"{}\"", self.center.x, self.center.y, self.radius);
225
226        if let Some(fill) = &self.fill {
227            attrs.push_str(&format!(" fill=\"{}\"", fill.to_css_hex()));
228        } else {
229            attrs.push_str(" fill=\"none\"");
230        }
231
232        if let Some(stroke) = &self.stroke {
233            attrs.push_str(&format!(
234                " stroke=\"{}\" stroke-width=\"{}\"",
235                stroke.to_css_hex(),
236                self.stroke_width
237            ));
238        }
239
240        format!("<circle {}/>", attrs)
241    }
242}
243
244impl Default for Circle {
245    fn default() -> Self {
246        Self::new(50.0, 50.0, 25.0)
247    }
248}
249
250/// A line segment
251#[derive(Debug, Clone, PartialEq)]
252pub struct Line {
253    /// Start point
254    pub start: Point,
255    /// End point
256    pub end: Point,
257    /// Stroke color
258    pub stroke: Color,
259    /// Stroke width
260    pub stroke_width: f32,
261    /// Dash array (for dashed lines)
262    pub dash_array: Option<String>,
263}
264
265impl Line {
266    /// Create a new line
267    pub fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
268        Self {
269            start: Point::new(x1, y1),
270            end: Point::new(x2, y2),
271            stroke: Color::rgb(0, 0, 0),
272            stroke_width: 1.0,
273            dash_array: None,
274        }
275    }
276
277    /// Set stroke color
278    pub fn with_stroke(mut self, color: Color) -> Self {
279        self.stroke = color;
280        self
281    }
282
283    /// Set stroke width
284    pub fn with_stroke_width(mut self, width: f32) -> Self {
285        self.stroke_width = width;
286        self
287    }
288
289    /// Set dash pattern
290    pub fn with_dash(mut self, pattern: &str) -> Self {
291        self.dash_array = Some(pattern.to_string());
292        self
293    }
294
295    /// Get the length of the line
296    pub fn length(&self) -> f32 {
297        self.start.distance(&self.end)
298    }
299
300    /// Get the midpoint
301    pub fn midpoint(&self) -> Point {
302        self.start.midpoint(&self.end)
303    }
304
305    /// Render to SVG element
306    pub fn to_svg(&self) -> String {
307        let mut attrs = format!(
308            "x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" stroke=\"{}\" stroke-width=\"{}\"",
309            self.start.x,
310            self.start.y,
311            self.end.x,
312            self.end.y,
313            self.stroke.to_css_hex(),
314            self.stroke_width
315        );
316
317        if let Some(dash) = &self.dash_array {
318            attrs.push_str(&format!(" stroke-dasharray=\"{}\"", dash));
319        }
320
321        format!("<line {}/>", attrs)
322    }
323}
324
325/// SVG path commands
326#[derive(Debug, Clone)]
327pub enum PathCommand {
328    /// Move to (x, y)
329    MoveTo(f32, f32),
330    /// Line to (x, y)
331    LineTo(f32, f32),
332    /// Horizontal line to x
333    HorizontalTo(f32),
334    /// Vertical line to y
335    VerticalTo(f32),
336    /// Quadratic curve to (x, y) with control point (cx, cy)
337    QuadraticTo { cx: f32, cy: f32, x: f32, y: f32 },
338    /// Cubic curve to (x, y) with control points
339    CubicTo { cx1: f32, cy1: f32, cx2: f32, cy2: f32, x: f32, y: f32 },
340    /// Arc to (x, y)
341    ArcTo { rx: f32, ry: f32, rotation: f32, large_arc: bool, sweep: bool, x: f32, y: f32 },
342    /// Close path
343    Close,
344}
345
346impl PathCommand {
347    /// Convert to SVG path data string
348    pub fn to_svg(&self) -> String {
349        match self {
350            Self::MoveTo(x, y) => format!("M {} {}", x, y),
351            Self::LineTo(x, y) => format!("L {} {}", x, y),
352            Self::HorizontalTo(x) => format!("H {}", x),
353            Self::VerticalTo(y) => format!("V {}", y),
354            Self::QuadraticTo { cx, cy, x, y } => format!("Q {} {} {} {}", cx, cy, x, y),
355            Self::CubicTo { cx1, cy1, cx2, cy2, x, y } => {
356                format!("C {} {} {} {} {} {}", cx1, cy1, cx2, cy2, x, y)
357            }
358            Self::ArcTo { rx, ry, rotation, large_arc, sweep, x, y } => format!(
359                "A {} {} {} {} {} {} {}",
360                rx,
361                ry,
362                rotation,
363                i32::from(*large_arc),
364                i32::from(*sweep),
365                x,
366                y
367            ),
368            Self::Close => "Z".to_string(),
369        }
370    }
371}
372
373/// A path shape
374#[derive(Debug, Clone)]
375pub struct Path {
376    /// Path commands
377    pub commands: Vec<PathCommand>,
378    /// Fill color
379    pub fill: Option<Color>,
380    /// Stroke color
381    pub stroke: Option<Color>,
382    /// Stroke width
383    pub stroke_width: f32,
384}
385
386impl Path {
387    /// Create a new empty path
388    pub fn new() -> Self {
389        Self { commands: Vec::new(), fill: None, stroke: None, stroke_width: 1.0 }
390    }
391
392    /// Move to a point
393    pub fn move_to(mut self, x: f32, y: f32) -> Self {
394        self.commands.push(PathCommand::MoveTo(x, y));
395        self
396    }
397
398    /// Line to a point
399    pub fn line_to(mut self, x: f32, y: f32) -> Self {
400        self.commands.push(PathCommand::LineTo(x, y));
401        self
402    }
403
404    /// Quadratic curve to a point
405    pub fn quad_to(mut self, cx: f32, cy: f32, x: f32, y: f32) -> Self {
406        self.commands.push(PathCommand::QuadraticTo { cx, cy, x, y });
407        self
408    }
409
410    /// Cubic curve to a point
411    pub fn cubic_to(mut self, cx1: f32, cy1: f32, cx2: f32, cy2: f32, x: f32, y: f32) -> Self {
412        self.commands.push(PathCommand::CubicTo { cx1, cy1, cx2, cy2, x, y });
413        self
414    }
415
416    /// Close the path
417    pub fn close(mut self) -> Self {
418        self.commands.push(PathCommand::Close);
419        self
420    }
421
422    /// Set fill color
423    pub fn with_fill(mut self, color: Color) -> Self {
424        self.fill = Some(color);
425        self
426    }
427
428    /// Set stroke
429    pub fn with_stroke(mut self, color: Color, width: f32) -> Self {
430        self.stroke = Some(color);
431        self.stroke_width = width;
432        self
433    }
434
435    /// Get the path data string
436    pub fn to_path_data(&self) -> String {
437        self.commands.iter().map(|c| c.to_svg()).collect::<Vec<_>>().join(" ")
438    }
439
440    /// Render to SVG element
441    pub fn to_svg(&self) -> String {
442        let mut attrs = format!("d=\"{}\"", self.to_path_data());
443
444        if let Some(fill) = &self.fill {
445            attrs.push_str(&format!(" fill=\"{}\"", fill.to_css_hex()));
446        } else {
447            attrs.push_str(" fill=\"none\"");
448        }
449
450        if let Some(stroke) = &self.stroke {
451            attrs.push_str(&format!(
452                " stroke=\"{}\" stroke-width=\"{}\"",
453                stroke.to_css_hex(),
454                self.stroke_width
455            ));
456        }
457
458        format!("<path {}/>", attrs)
459    }
460}
461
462impl Default for Path {
463    fn default() -> Self {
464        Self::new()
465    }
466}
467
468/// A text element
469#[derive(Debug, Clone)]
470pub struct Text {
471    /// Position
472    pub position: Point,
473    /// Text content
474    pub content: String,
475    /// Style
476    pub style: TextStyle,
477}
478
479impl Text {
480    /// Create a new text element
481    pub fn new(x: f32, y: f32, content: &str) -> Self {
482        Self {
483            position: Point::new(x, y),
484            content: content.to_string(),
485            style: TextStyle::default(),
486        }
487    }
488
489    /// Set the text style
490    pub fn with_style(mut self, style: TextStyle) -> Self {
491        self.style = style;
492        self
493    }
494
495    /// Render to SVG element
496    pub fn to_svg(&self) -> String {
497        let style_attrs = self.style.to_svg_attrs();
498        format!(
499            "<text x=\"{}\" y=\"{}\" {}>{}</text>",
500            self.position.x,
501            self.position.y,
502            style_attrs,
503            html_escape(&self.content)
504        )
505    }
506}
507
508/// Escape HTML special characters
509fn html_escape(s: &str) -> String {
510    s.replace('&', "&amp;")
511        .replace('<', "&lt;")
512        .replace('>', "&gt;")
513        .replace('"', "&quot;")
514        .replace('\'', "&#39;")
515}
516
517/// Arrow marker for line endings
518#[derive(Debug, Clone)]
519pub struct ArrowMarker {
520    /// Marker ID
521    pub id: String,
522    /// Arrow color
523    pub color: Color,
524    /// Arrow size
525    pub size: f32,
526}
527
528impl ArrowMarker {
529    /// Create a new arrow marker
530    pub fn new(id: &str, color: Color) -> Self {
531        Self { id: id.to_string(), color, size: 10.0 }
532    }
533
534    /// Set the arrow size
535    pub fn with_size(mut self, size: f32) -> Self {
536        self.size = size;
537        self
538    }
539
540    /// Render to SVG marker definition
541    pub fn to_svg_def(&self) -> String {
542        format!(
543            r#"<marker id="{}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="{}" markerHeight="{}" orient="auto-start-reverse">
544  <path d="M 0 0 L 10 5 L 0 10 z" fill="{}"/>
545</marker>"#,
546            self.id,
547            self.size,
548            self.size,
549            self.color.to_css_hex()
550        )
551    }
552}
553
554#[cfg(test)]
555#[path = "shapes_tests.rs"]
556mod tests;