Skip to main content

draw_core/render/
mod.rs

1//! Unified tiny-skia renderer for both native and WASM targets.
2//!
3//! Split into submodules:
4//! - `draw`: element rendering, fill patterns, grid
5//! - `selection`: selection box and rubber band visuals
6//! - `hit_test`: hit testing for elements, handles, and selection rects
7//! - `path`: tiny-skia path builders for shapes
8
9mod draw;
10mod hit_test;
11mod path;
12mod selection;
13
14use tiny_skia::*;
15
16use crate::Document;
17use crate::geometry;
18use crate::point::{Bounds, ViewState};
19use crate::style::StrokeStyle;
20
21use hit_test::{element_bounds_f32, rects_intersect, to_skia_rect_from_bounds};
22
23// ── Theme constants (matches frontend/theme.js) ────────────────────────
24
25const BG_R: u8 = 10;
26const BG_G: u8 = 15;
27const BG_B: u8 = 26;
28
29const GRID_R: u8 = 59;
30const GRID_G: u8 = 130;
31const GRID_B: u8 = 246;
32const GRID_ALPHA: f32 = 0.08;
33
34const DEFAULT_STROKE_R: u8 = 168;
35const DEFAULT_STROKE_G: u8 = 85;
36const DEFAULT_STROKE_B: u8 = 247;
37
38const ACCENT_R: u8 = 59;
39const ACCENT_G: u8 = 130;
40const ACCENT_B: u8 = 246;
41
42const SELECTION_FILL_ALPHA: f32 = 0.08;
43
44const HANDLE_FILL_R: u8 = 255;
45const HANDLE_FILL_G: u8 = 255;
46const HANDLE_FILL_B: u8 = 255;
47
48const CORNER_RADIUS: f32 = 12.0;
49
50const HACHURE_LINE_WIDTH: f32 = geometry::HACHURE_LINE_WIDTH as f32;
51const HACHURE_ALPHA: f32 = 0.5;
52
53const SELECTION_PAD: f32 = 5.0;
54const SELECTION_DASH_LEN: f32 = 5.0;
55const HANDLE_RADIUS: f32 = 4.0;
56
57const GRID_SIZE: f32 = 20.0;
58const GRID_MIN_SCREEN_PX: f32 = 8.0;
59
60const HIT_TEST_PAD: f32 = 4.0;
61const LINE_HIT_TOLERANCE: f32 = 6.0;
62
63const TEXT_CHAR_WIDTH_FACTOR: f32 = 0.6;
64const TEXT_LINE_HEIGHT_FACTOR: f32 = 1.2;
65const TEXT_MIN_CHARS: f32 = 2.0;
66
67// ── Public types ────────────────────────────────────────────────────────
68
69#[derive(Clone)]
70pub struct RenderConfig {
71    pub width: u32,
72    pub height: u32,
73    pub background: Color,
74    pub pixel_ratio: f32,
75    pub show_grid: bool,
76}
77
78impl Default for RenderConfig {
79    fn default() -> Self {
80        Self {
81            width: 1920,
82            height: 1080,
83            background: Color::from_rgba8(BG_R, BG_G, BG_B, 255),
84            pixel_ratio: 1.0,
85            show_grid: true,
86        }
87    }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum HandlePosition {
92    NorthWest,
93    NorthEast,
94    SouthWest,
95    SouthEast,
96}
97
98/// The renderer. Stateless aside from config — call `render()` each frame.
99pub struct Renderer {
100    pub(self) config: RenderConfig,
101}
102
103impl Renderer {
104    pub fn new(config: RenderConfig) -> Self {
105        Self { config }
106    }
107
108    pub fn config(&self) -> &RenderConfig {
109        &self.config
110    }
111
112    // ── Main render entry point ─────────────────────────────────────
113
114    /// Render the document to a pixmap.
115    ///
116    /// # Panics
117    /// Panics if `tiny_skia::Pixmap::new` fails to allocate. Width and height
118    /// are clamped to at least 1 via `.max(1)`, so the remaining failure modes
119    /// are allocator OOM or dimensions whose `width * height * 4` overflows
120    /// `i32::MAX` — conditions the caller is responsible for avoiding via
121    /// [`RenderConfig`] sizing.
122    pub fn render(
123        &self,
124        doc: &Document,
125        viewport: &ViewState,
126        selected_ids: &[&str],
127        selection_box: Option<Bounds>,
128    ) -> Pixmap {
129        let pw = (self.config.width as f32 * self.config.pixel_ratio) as u32;
130        let ph = (self.config.height as f32 * self.config.pixel_ratio) as u32;
131        let mut pixmap = Pixmap::new(pw.max(1), ph.max(1)).expect("pixmap dimensions must be > 0");
132
133        pixmap.fill(self.config.background);
134
135        if self.config.show_grid {
136            self.draw_grid(&mut pixmap, viewport);
137        }
138
139        let vt = viewport_transform(viewport, self.config.pixel_ratio);
140
141        for el in &doc.elements {
142            self.draw_element(&mut pixmap, el, &vt);
143        }
144
145        for el in &doc.elements {
146            if selected_ids.contains(&el.id()) && el.group_id().is_none() {
147                self.draw_selection_box(&mut pixmap, el, viewport);
148            }
149        }
150
151        if let Some(sb) = selection_box {
152            self.draw_rubber_band(&mut pixmap, &sb);
153        }
154
155        pixmap
156    }
157
158    // ── Hit testing ─────────────────────────────────────────────────
159
160    pub fn hit_test(
161        &self,
162        doc: &Document,
163        viewport: &ViewState,
164        screen_x: f32,
165        screen_y: f32,
166    ) -> Option<String> {
167        let (wx, wy) = screen_to_world(viewport, screen_x, screen_y);
168        for el in doc.elements.iter().rev() {
169            if self.hit_test_element(el, wx, wy) {
170                return Some(el.id().to_string());
171            }
172        }
173        None
174    }
175
176    pub fn hit_test_handle(
177        &self,
178        doc: &Document,
179        viewport: &ViewState,
180        screen_x: f32,
181        screen_y: f32,
182    ) -> Option<(String, HandlePosition)> {
183        let (wx, wy) = screen_to_world(viewport, screen_x, screen_y);
184        let hs = HANDLE_RADIUS / viewport.zoom as f32;
185
186        for el in doc.elements.iter().rev() {
187            if let Some(eb) = element_bounds_f32(el) {
188                let handles = [
189                    (HandlePosition::NorthWest, eb.x(), eb.y()),
190                    (HandlePosition::NorthEast, eb.x() + eb.width(), eb.y()),
191                    (HandlePosition::SouthWest, eb.x(), eb.y() + eb.height()),
192                    (
193                        HandlePosition::SouthEast,
194                        eb.x() + eb.width(),
195                        eb.y() + eb.height(),
196                    ),
197                ];
198                for (pos, hx, hy) in &handles {
199                    if (wx - hx).abs() < hs && (wy - hy).abs() < hs {
200                        return Some((el.id().to_string(), *pos));
201                    }
202                }
203            }
204        }
205        None
206    }
207
208    pub fn elements_in_rect(
209        &self,
210        doc: &Document,
211        _viewport: &ViewState,
212        rect: Bounds,
213    ) -> Vec<String> {
214        let sel = to_skia_rect_from_bounds(&rect);
215        let mut result = Vec::new();
216        if let Some(sel) = sel {
217            for el in &doc.elements {
218                if let Some(eb) = element_bounds_f32(el)
219                    && rects_intersect(&sel, &eb)
220                {
221                    result.push(el.id().to_string());
222                }
223            }
224        }
225        result
226    }
227}
228
229// ── Viewport helpers ───────────────────────────────────────────────────
230
231fn viewport_transform(viewport: &ViewState, pixel_ratio: f32) -> Transform {
232    let pr = pixel_ratio;
233    let zoom = viewport.zoom as f32 * pr;
234    let tx = viewport.scroll_x as f32 * pr;
235    let ty = viewport.scroll_y as f32 * pr;
236    Transform::from_row(zoom, 0.0, 0.0, zoom, tx, ty)
237}
238
239fn screen_to_world(viewport: &ViewState, sx: f32, sy: f32) -> (f32, f32) {
240    let wx = (sx - viewport.scroll_x as f32) / viewport.zoom as f32;
241    let wy = (sy - viewport.scroll_y as f32) / viewport.zoom as f32;
242    (wx, wy)
243}
244
245// ── Color & stroke helpers ─────────────────────────────────────────────
246
247fn parse_color(hex: &str, opacity: f32) -> Color {
248    let hex = hex.trim().trim_start_matches('#');
249    let (r, g, b) = match hex.len() {
250        6 => {
251            let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(DEFAULT_STROKE_R);
252            let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(DEFAULT_STROKE_G);
253            let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(DEFAULT_STROKE_B);
254            (r, g, b)
255        }
256        3 => {
257            let r = u8::from_str_radix(&hex[0..1], 16)
258                .map(|v| v * 17)
259                .unwrap_or(DEFAULT_STROKE_R);
260            let g = u8::from_str_radix(&hex[1..2], 16)
261                .map(|v| v * 17)
262                .unwrap_or(DEFAULT_STROKE_G);
263            let b = u8::from_str_radix(&hex[2..3], 16)
264                .map(|v| v * 17)
265                .unwrap_or(DEFAULT_STROKE_B);
266            (r, g, b)
267        }
268        _ => (DEFAULT_STROKE_R, DEFAULT_STROKE_G, DEFAULT_STROKE_B),
269    };
270    let a = (opacity * 255.0).clamp(0.0, 255.0) as u8;
271    Color::from_rgba8(r, g, b, a)
272}
273
274fn stroke_from_style(style: &StrokeStyle, opacity: f32) -> (Paint<'static>, Stroke) {
275    let color = parse_color(&style.color, opacity);
276    let mut paint = Paint::default();
277    paint.set_color(color);
278    paint.anti_alias = true;
279
280    let dash = if style.dash.is_empty() {
281        None
282    } else {
283        let dash_vals: Vec<f32> = style.dash.iter().map(|d| *d as f32).collect();
284        StrokeDash::new(dash_vals, 0.0)
285    };
286
287    let stroke = Stroke {
288        width: style.width as f32,
289        line_cap: LineCap::Round,
290        line_join: LineJoin::Round,
291        dash,
292        ..Stroke::default()
293    };
294    (paint, stroke)
295}
296
297// ── Tests ───────────────────────────────────────────────────────────────
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::element::{Element, FreeDrawElement, LineElement, ShapeElement, TextElement};
303    use crate::point::Point;
304    use crate::style::{FillStyle, FillType};
305    use hit_test::point_to_segment_distance;
306
307    fn make_doc() -> Document {
308        Document::new("test".to_string())
309    }
310
311    fn default_viewport() -> ViewState {
312        ViewState::default()
313    }
314
315    fn small_config() -> RenderConfig {
316        RenderConfig {
317            width: 200,
318            height: 200,
319            ..RenderConfig::default()
320        }
321    }
322
323    #[test]
324    fn test_render_empty_document_produces_background() {
325        let mut config = small_config();
326        config.show_grid = false;
327        let renderer = Renderer::new(config);
328        let doc = make_doc();
329        let vp = default_viewport();
330        let pixmap = renderer.render(&doc, &vp, &[], None);
331        assert_eq!(pixmap.width(), 200);
332        assert_eq!(pixmap.height(), 200);
333
334        let data = pixmap.data();
335        assert_eq!(data[0], BG_R);
336        assert_eq!(data[1], BG_G);
337        assert_eq!(data[2], BG_B);
338        assert_eq!(data[3], 255);
339    }
340
341    #[test]
342    fn test_render_with_elements_not_empty() {
343        let renderer = Renderer::new(small_config());
344        let mut doc = make_doc();
345        doc.add_element(Element::Rectangle(ShapeElement::new(
346            "r1".into(),
347            10.0,
348            10.0,
349            80.0,
350            60.0,
351        )));
352        let vp = default_viewport();
353        let pixmap = renderer.render(&doc, &vp, &[], None);
354        assert!(pixmap.width() > 0);
355        let bg_pixmap = Renderer::new(small_config()).render(&make_doc(), &vp, &[], None);
356        assert_ne!(pixmap.data(), bg_pixmap.data());
357    }
358
359    #[test]
360    fn test_render_rectangle() {
361        let renderer = Renderer::new(small_config());
362        let mut doc = make_doc();
363        doc.add_element(Element::Rectangle(ShapeElement::new(
364            "r1".into(),
365            5.0,
366            5.0,
367            50.0,
368            40.0,
369        )));
370        let _ = renderer.render(&doc, &default_viewport(), &[], None);
371    }
372
373    #[test]
374    fn test_render_ellipse() {
375        let renderer = Renderer::new(small_config());
376        let mut doc = make_doc();
377        doc.add_element(Element::Ellipse(ShapeElement::new(
378            "e1".into(),
379            10.0,
380            10.0,
381            60.0,
382            40.0,
383        )));
384        let _ = renderer.render(&doc, &default_viewport(), &[], None);
385    }
386
387    #[test]
388    fn test_render_diamond() {
389        let renderer = Renderer::new(small_config());
390        let mut doc = make_doc();
391        doc.add_element(Element::Diamond(ShapeElement::new(
392            "d1".into(),
393            10.0,
394            10.0,
395            60.0,
396            60.0,
397        )));
398        let _ = renderer.render(&doc, &default_viewport(), &[], None);
399    }
400
401    #[test]
402    fn test_render_line() {
403        let renderer = Renderer::new(small_config());
404        let mut doc = make_doc();
405        doc.add_element(Element::Line(LineElement::new(
406            "l1".into(),
407            0.0,
408            0.0,
409            vec![Point::new(10.0, 10.0), Point::new(90.0, 90.0)],
410        )));
411        let _ = renderer.render(&doc, &default_viewport(), &[], None);
412    }
413
414    #[test]
415    fn test_render_arrow() {
416        let renderer = Renderer::new(small_config());
417        let mut doc = make_doc();
418        doc.add_element(Element::Arrow(LineElement::new(
419            "a1".into(),
420            0.0,
421            0.0,
422            vec![Point::new(10.0, 10.0), Point::new(90.0, 50.0)],
423        )));
424        let _ = renderer.render(&doc, &default_viewport(), &[], None);
425    }
426
427    #[test]
428    fn test_render_freedraw() {
429        let renderer = Renderer::new(small_config());
430        let mut doc = make_doc();
431        doc.add_element(Element::FreeDraw(FreeDrawElement::new(
432            "fd1".into(),
433            0.0,
434            0.0,
435            vec![
436                Point::new(10.0, 10.0),
437                Point::new(20.0, 30.0),
438                Point::new(40.0, 20.0),
439                Point::new(60.0, 50.0),
440            ],
441        )));
442        let _ = renderer.render(&doc, &default_viewport(), &[], None);
443    }
444
445    #[test]
446    fn test_render_text_placeholder() {
447        let renderer = Renderer::new(small_config());
448        let mut doc = make_doc();
449        doc.add_element(Element::Text(TextElement::new(
450            "t1".into(),
451            10.0,
452            10.0,
453            "Hello world".into(),
454        )));
455        let _ = renderer.render(&doc, &default_viewport(), &[], None);
456    }
457
458    #[test]
459    fn test_render_solid_fill() {
460        let renderer = Renderer::new(small_config());
461        let mut doc = make_doc();
462        let mut rect = ShapeElement::new("r1".into(), 10.0, 10.0, 80.0, 60.0);
463        rect.fill = FillStyle {
464            style: FillType::Solid,
465            ..FillStyle::default()
466        };
467        doc.add_element(Element::Rectangle(rect));
468        let _ = renderer.render(&doc, &default_viewport(), &[], None);
469    }
470
471    #[test]
472    fn test_render_hachure_fill() {
473        let renderer = Renderer::new(small_config());
474        let mut doc = make_doc();
475        let mut rect = ShapeElement::new("r1".into(), 10.0, 10.0, 80.0, 60.0);
476        rect.fill = FillStyle {
477            style: FillType::Hachure,
478            ..FillStyle::default()
479        };
480        doc.add_element(Element::Rectangle(rect));
481        let _ = renderer.render(&doc, &default_viewport(), &[], None);
482    }
483
484    #[test]
485    fn test_render_crosshatch_fill() {
486        let renderer = Renderer::new(small_config());
487        let mut doc = make_doc();
488        let mut rect = ShapeElement::new("r1".into(), 10.0, 10.0, 80.0, 60.0);
489        rect.fill = FillStyle {
490            style: FillType::CrossHatch,
491            ..FillStyle::default()
492        };
493        doc.add_element(Element::Rectangle(rect));
494        let _ = renderer.render(&doc, &default_viewport(), &[], None);
495    }
496
497    #[test]
498    fn test_hit_test_rectangle() {
499        let renderer = Renderer::new(small_config());
500        let mut doc = make_doc();
501        doc.add_element(Element::Rectangle(ShapeElement::new(
502            "r1".into(),
503            50.0,
504            50.0,
505            100.0,
506            80.0,
507        )));
508        let vp = default_viewport();
509        assert_eq!(renderer.hit_test(&doc, &vp, 80.0, 80.0), Some("r1".into()));
510        assert!(renderer.hit_test(&doc, &vp, 5.0, 5.0).is_none());
511    }
512
513    #[test]
514    fn test_hit_test_ellipse() {
515        let renderer = Renderer::new(small_config());
516        let mut doc = make_doc();
517        doc.add_element(Element::Ellipse(ShapeElement::new(
518            "e1".into(),
519            50.0,
520            50.0,
521            100.0,
522            60.0,
523        )));
524        let vp = default_viewport();
525        assert_eq!(renderer.hit_test(&doc, &vp, 100.0, 80.0), Some("e1".into()));
526    }
527
528    #[test]
529    fn test_hit_test_line() {
530        let renderer = Renderer::new(small_config());
531        let mut doc = make_doc();
532        doc.add_element(Element::Line(LineElement::new(
533            "l1".into(),
534            0.0,
535            0.0,
536            vec![Point::new(10.0, 10.0), Point::new(100.0, 100.0)],
537        )));
538        let vp = default_viewport();
539        assert_eq!(renderer.hit_test(&doc, &vp, 55.0, 55.0), Some("l1".into()));
540        assert!(renderer.hit_test(&doc, &vp, 10.0, 100.0).is_none());
541    }
542
543    #[test]
544    fn test_hit_test_returns_topmost() {
545        let renderer = Renderer::new(small_config());
546        let mut doc = make_doc();
547        doc.add_element(Element::Rectangle(ShapeElement::new(
548            "r_bottom".into(),
549            10.0,
550            10.0,
551            100.0,
552            100.0,
553        )));
554        doc.add_element(Element::Rectangle(ShapeElement::new(
555            "r_top".into(),
556            20.0,
557            20.0,
558            80.0,
559            80.0,
560        )));
561        let vp = default_viewport();
562        assert_eq!(
563            renderer.hit_test(&doc, &vp, 50.0, 50.0),
564            Some("r_top".into())
565        );
566    }
567
568    #[test]
569    fn test_screen_to_world_identity() {
570        let vp = default_viewport();
571        let (wx, wy) = screen_to_world(&vp, 100.0, 200.0);
572        assert!((wx - 100.0).abs() < 0.01);
573        assert!((wy - 200.0).abs() < 0.01);
574    }
575
576    #[test]
577    fn test_screen_to_world_with_zoom() {
578        let vp = ViewState {
579            scroll_x: 0.0,
580            scroll_y: 0.0,
581            zoom: 2.0,
582        };
583        let (wx, wy) = screen_to_world(&vp, 100.0, 200.0);
584        assert!((wx - 50.0).abs() < 0.01);
585        assert!((wy - 100.0).abs() < 0.01);
586    }
587
588    #[test]
589    fn test_screen_to_world_with_scroll() {
590        let vp = ViewState {
591            scroll_x: 50.0,
592            scroll_y: 30.0,
593            zoom: 1.0,
594        };
595        let (wx, wy) = screen_to_world(&vp, 100.0, 80.0);
596        assert!((wx - 50.0).abs() < 0.01);
597        assert!((wy - 50.0).abs() < 0.01);
598    }
599
600    #[test]
601    fn test_viewport_transform_identity() {
602        let vp = default_viewport();
603        let t = viewport_transform(&vp, 1.0);
604        assert!((t.sx - 1.0).abs() < 0.01);
605        assert!((t.sy - 1.0).abs() < 0.01);
606        assert!(t.tx.abs() < 0.01);
607        assert!(t.ty.abs() < 0.01);
608    }
609
610    #[test]
611    fn test_viewport_transform_with_zoom_and_scroll() {
612        let vp = ViewState {
613            scroll_x: 100.0,
614            scroll_y: 50.0,
615            zoom: 2.0,
616        };
617        let t = viewport_transform(&vp, 1.0);
618        assert!((t.sx - 2.0).abs() < 0.01);
619        assert!((t.tx - 100.0).abs() < 0.01);
620        assert!((t.ty - 50.0).abs() < 0.01);
621    }
622
623    #[test]
624    fn test_elements_in_rect() {
625        let renderer = Renderer::new(small_config());
626        let mut doc = make_doc();
627        doc.add_element(Element::Rectangle(ShapeElement::new(
628            "r1".into(),
629            10.0,
630            10.0,
631            30.0,
632            30.0,
633        )));
634        doc.add_element(Element::Rectangle(ShapeElement::new(
635            "r2".into(),
636            100.0,
637            100.0,
638            30.0,
639            30.0,
640        )));
641        let vp = default_viewport();
642        let ids = renderer.elements_in_rect(&doc, &vp, Bounds::new(0.0, 0.0, 50.0, 50.0));
643        assert!(ids.contains(&"r1".to_string()));
644        assert!(!ids.contains(&"r2".to_string()));
645    }
646
647    #[test]
648    fn test_parse_color_hex6() {
649        let c = parse_color("#3b82f6", 1.0);
650        assert_eq!(c, Color::from_rgba8(0x3b, 0x82, 0xf6, 255));
651    }
652
653    #[test]
654    fn test_parse_color_hex3() {
655        let c = parse_color("#fff", 1.0);
656        assert_eq!(c, Color::from_rgba8(255, 255, 255, 255));
657    }
658
659    #[test]
660    fn test_parse_color_with_opacity() {
661        let c = parse_color("#ffffff", 0.5);
662        assert_eq!(c, Color::from_rgba8(255, 255, 255, 127));
663    }
664
665    #[test]
666    fn test_parse_color_invalid_fallback() {
667        let c = parse_color("not-a-color", 1.0);
668        assert_eq!(
669            c,
670            Color::from_rgba8(DEFAULT_STROKE_R, DEFAULT_STROKE_G, DEFAULT_STROKE_B, 255)
671        );
672    }
673
674    #[test]
675    fn test_point_to_segment_distance() {
676        let d = point_to_segment_distance(5.0, 5.0, 0.0, 0.0, 10.0, 10.0);
677        assert!(d < 0.01);
678        let d = point_to_segment_distance(0.0, 10.0, 0.0, 0.0, 10.0, 0.0);
679        assert!((d - 10.0).abs() < 0.01);
680    }
681
682    #[test]
683    fn test_render_with_selection() {
684        let renderer = Renderer::new(small_config());
685        let mut doc = make_doc();
686        doc.add_element(Element::Rectangle(ShapeElement::new(
687            "r1".into(),
688            10.0,
689            10.0,
690            80.0,
691            60.0,
692        )));
693        let _ = renderer.render(&doc, &default_viewport(), &["r1"], None);
694    }
695
696    #[test]
697    fn test_render_with_rubber_band() {
698        let renderer = Renderer::new(small_config());
699        let doc = make_doc();
700        let sb = Bounds::new(10.0, 10.0, 100.0, 80.0);
701        let _ = renderer.render(&doc, &default_viewport(), &[], Some(sb));
702    }
703
704    #[test]
705    fn test_render_negative_dimensions() {
706        let renderer = Renderer::new(small_config());
707        let mut doc = make_doc();
708        doc.add_element(Element::Rectangle(ShapeElement::new(
709            "r1".into(),
710            100.0,
711            100.0,
712            -50.0,
713            -30.0,
714        )));
715        let _ = renderer.render(&doc, &default_viewport(), &[], None);
716    }
717}