Skip to main content

draw_core/
render.rs

1//! Unified tiny-skia renderer for both native and WASM targets.
2//!
3//! This module implements the full rendering pipeline: viewport transform,
4//! element drawing (shapes, lines, text placeholders), fill patterns (solid,
5//! hachure, crosshatch), selection visuals, and hit testing.
6//!
7//! # Text rendering
8//! TODO: Text is currently rendered as a colored placeholder rectangle with
9//! approximate bounds. Full glyph rendering requires cosmic-text integration,
10//! which will be added in a follow-up.
11
12use tiny_skia::*;
13
14use crate::Document;
15use crate::element::{Element, FreeDrawElement, LineElement, ShapeElement, TextElement};
16use crate::point::{Bounds, ViewState};
17use crate::style::{FillStyle, FillType, StrokeStyle};
18
19// ── Theme constants (matches frontend/theme.js) ────────────────────────
20
21/// Canvas background color (#0a0f1a)
22const BG_R: u8 = 10;
23const BG_G: u8 = 15;
24const BG_B: u8 = 26;
25
26/// Grid line color — rgba(59, 130, 246, 0.08)
27const GRID_R: u8 = 59;
28const GRID_G: u8 = 130;
29const GRID_B: u8 = 246;
30const GRID_ALPHA: f32 = 0.08;
31
32/// Default stroke color (#e2e8f0)
33const DEFAULT_STROKE_R: u8 = 226;
34const DEFAULT_STROKE_G: u8 = 232;
35const DEFAULT_STROKE_B: u8 = 240;
36
37/// Accent / selection color (#3b82f6)
38const ACCENT_R: u8 = 59;
39const ACCENT_G: u8 = 130;
40const ACCENT_B: u8 = 246;
41
42/// Selection fill alpha — rgba(59, 130, 246, 0.08)
43const SELECTION_FILL_ALPHA: f32 = 0.08;
44
45/// Handle fill (#ffffff)
46const HANDLE_FILL_R: u8 = 255;
47const HANDLE_FILL_G: u8 = 255;
48const HANDLE_FILL_B: u8 = 255;
49
50/// Corner radius for rectangles and diamonds (px)
51const CORNER_RADIUS: f32 = 12.0;
52
53/// Arrowhead geometry
54pub const ARROWHEAD_LENGTH: f32 = 14.0;
55pub const ARROWHEAD_ANGLE: f32 = 0.45;
56
57/// Hachure fill
58const HACHURE_LINE_WIDTH: f32 = 1.5;
59const HACHURE_ALPHA: f32 = 0.5;
60
61/// Selection visuals
62const SELECTION_PAD: f32 = 5.0;
63const SELECTION_DASH_LEN: f32 = 5.0;
64const HANDLE_RADIUS: f32 = 4.0;
65
66/// Grid spacing (world units)
67const GRID_SIZE: f32 = 20.0;
68/// Minimum screen-space grid spacing before hiding
69const GRID_MIN_SCREEN_PX: f32 = 8.0;
70
71/// Hit-test padding (world units)
72const HIT_TEST_PAD: f32 = 4.0;
73
74/// Line proximity tolerance for hit testing lines/arrows (world units)
75const LINE_HIT_TOLERANCE: f32 = 6.0;
76
77/// Approximate character width factor for text bounds
78const TEXT_CHAR_WIDTH_FACTOR: f32 = 0.6;
79/// Line height factor for text bounds
80const TEXT_LINE_HEIGHT_FACTOR: f32 = 1.2;
81/// Minimum text width in character-equivalents
82const TEXT_MIN_CHARS: f32 = 2.0;
83
84// ── Public types ────────────────────────────────────────────────────────
85
86/// Configuration for the renderer.
87#[derive(Clone)]
88pub struct RenderConfig {
89    pub width: u32,
90    pub height: u32,
91    pub background: Color,
92    /// Device pixel ratio (2.0 for retina)
93    pub pixel_ratio: f32,
94    pub show_grid: bool,
95}
96
97impl Default for RenderConfig {
98    fn default() -> Self {
99        Self {
100            width: 1920,
101            height: 1080,
102            background: Color::from_rgba8(BG_R, BG_G, BG_B, 255),
103            pixel_ratio: 1.0,
104            show_grid: true,
105        }
106    }
107}
108
109/// Resize handle position on a selection box.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum HandlePosition {
112    NorthWest,
113    NorthEast,
114    SouthWest,
115    SouthEast,
116}
117
118/// The renderer. Stateless aside from config — call `render()` each frame.
119pub struct Renderer {
120    config: RenderConfig,
121}
122
123impl Renderer {
124    pub fn new(config: RenderConfig) -> Self {
125        Self { config }
126    }
127
128    /// Access renderer configuration.
129    pub fn config(&self) -> &RenderConfig {
130        &self.config
131    }
132
133    // ── Main render entry point ─────────────────────────────────────
134
135    /// Render the full document to a pixel buffer.
136    pub fn render(
137        &self,
138        doc: &Document,
139        viewport: &ViewState,
140        selected_ids: &[&str],
141        selection_box: Option<Bounds>,
142    ) -> Pixmap {
143        let pw = (self.config.width as f32 * self.config.pixel_ratio) as u32;
144        let ph = (self.config.height as f32 * self.config.pixel_ratio) as u32;
145        let mut pixmap = Pixmap::new(pw.max(1), ph.max(1)).expect("pixmap dimensions must be > 0");
146
147        // Background fill
148        pixmap.fill(self.config.background);
149
150        // Grid
151        if self.config.show_grid {
152            self.draw_grid(&mut pixmap, viewport);
153        }
154
155        // Viewport transform: screen = world * zoom + scroll
156        let vt = viewport_transform(viewport, self.config.pixel_ratio);
157
158        // Draw elements in z-order (front to back)
159        for el in &doc.elements {
160            self.draw_element(&mut pixmap, el, &vt);
161        }
162
163        // Selection highlights
164        for el in &doc.elements {
165            if selected_ids.contains(&el.id()) && !has_group_id(el) {
166                self.draw_selection_box(&mut pixmap, el, viewport);
167            }
168        }
169
170        // Rubber band selection rectangle (in screen coords)
171        if let Some(sb) = selection_box {
172            self.draw_rubber_band(&mut pixmap, &sb);
173        }
174
175        pixmap
176    }
177
178    // ── Hit testing ─────────────────────────────────────────────────
179
180    /// Hit test: which element is at this screen point? Returns element id.
181    /// Iterates in reverse z-order (topmost first).
182    pub fn hit_test(
183        &self,
184        doc: &Document,
185        viewport: &ViewState,
186        screen_x: f32,
187        screen_y: f32,
188    ) -> Option<String> {
189        let (wx, wy) = screen_to_world(viewport, screen_x, screen_y);
190        for el in doc.elements.iter().rev() {
191            if self.hit_test_element(el, wx, wy) {
192                return Some(el.id().to_string());
193            }
194        }
195        None
196    }
197
198    /// Hit test for resize handles. Returns (element_id, handle_position).
199    pub fn hit_test_handle(
200        &self,
201        doc: &Document,
202        viewport: &ViewState,
203        screen_x: f32,
204        screen_y: f32,
205    ) -> Option<(String, HandlePosition)> {
206        let (wx, wy) = screen_to_world(viewport, screen_x, screen_y);
207        let hs = HANDLE_RADIUS / viewport.zoom as f32;
208
209        for el in doc.elements.iter().rev() {
210            if let Some(eb) = element_bounds_f32(el) {
211                let handles = [
212                    (HandlePosition::NorthWest, eb.x(), eb.y()),
213                    (HandlePosition::NorthEast, eb.x() + eb.width(), eb.y()),
214                    (HandlePosition::SouthWest, eb.x(), eb.y() + eb.height()),
215                    (
216                        HandlePosition::SouthEast,
217                        eb.x() + eb.width(),
218                        eb.y() + eb.height(),
219                    ),
220                ];
221                for (pos, hx, hy) in &handles {
222                    if (wx - hx).abs() < hs && (wy - hy).abs() < hs {
223                        return Some((el.id().to_string(), *pos));
224                    }
225                }
226            }
227        }
228        None
229    }
230
231    /// Get element ids within a selection rectangle (world coords in Bounds).
232    pub fn elements_in_rect(
233        &self,
234        doc: &Document,
235        _viewport: &ViewState,
236        rect: Bounds,
237    ) -> Vec<String> {
238        let sel = to_skia_rect_from_bounds(&rect);
239        let mut result = Vec::new();
240        if let Some(sel) = sel {
241            for el in &doc.elements {
242                if let Some(eb) = element_bounds_f32(el)
243                    && rects_intersect(&sel, &eb)
244                {
245                    result.push(el.id().to_string());
246                }
247            }
248        }
249        result
250    }
251
252    // ── Element drawing ─────────────────────────────────────────────
253
254    fn draw_element(&self, pixmap: &mut Pixmap, el: &Element, transform: &Transform) {
255        let opacity = el.opacity() as f32;
256        match el {
257            Element::Rectangle(e) => self.draw_rectangle(pixmap, e, transform, opacity),
258            Element::Ellipse(e) => self.draw_ellipse(pixmap, e, transform, opacity),
259            Element::Diamond(e) => self.draw_diamond(pixmap, e, transform, opacity),
260            Element::Line(e) => self.draw_line(pixmap, e, transform, opacity),
261            Element::Arrow(e) => self.draw_arrow(pixmap, e, transform, opacity),
262            Element::FreeDraw(e) => self.draw_freedraw(pixmap, e, transform, opacity),
263            Element::Text(e) => self.draw_text(pixmap, e, transform, opacity),
264        }
265    }
266
267    fn draw_rectangle(
268        &self,
269        pixmap: &mut Pixmap,
270        el: &ShapeElement,
271        transform: &Transform,
272        opacity: f32,
273    ) {
274        let x = if el.width < 0.0 {
275            el.x + el.width
276        } else {
277            el.x
278        } as f32;
279        let y = if el.height < 0.0 {
280            el.y + el.height
281        } else {
282            el.y
283        } as f32;
284        let w = (el.width).abs() as f32;
285        let h = (el.height).abs() as f32;
286        if w < 0.5 && h < 0.5 {
287            return;
288        }
289
290        let radius = CORNER_RADIUS.min(w / 3.0).min(h / 3.0);
291        if let Some(path) = build_rounded_rect_path(x, y, w, h, radius) {
292            // Fill
293            self.fill_shape(pixmap, &path, &el.fill, x, y, w, h, transform, opacity);
294            // Stroke
295            let (paint, stroke) = stroke_from_style(&el.stroke, opacity);
296            pixmap.stroke_path(&path, &paint, &stroke, *transform, None);
297        }
298    }
299
300    fn draw_ellipse(
301        &self,
302        pixmap: &mut Pixmap,
303        el: &ShapeElement,
304        transform: &Transform,
305        opacity: f32,
306    ) {
307        let rx = (el.width).abs() as f32 / 2.0;
308        let ry = (el.height).abs() as f32 / 2.0;
309        if rx < 0.5 && ry < 0.5 {
310            return;
311        }
312
313        let cx = el.x as f32 + el.width as f32 / 2.0;
314        let cy = el.y as f32 + el.height as f32 / 2.0;
315        let safe_rx = rx.max(0.1);
316        let safe_ry = ry.max(0.1);
317
318        if let Some(path) = build_ellipse_path(cx, cy, safe_rx, safe_ry) {
319            let bx = if el.width < 0.0 {
320                el.x + el.width
321            } else {
322                el.x
323            } as f32;
324            let by = if el.height < 0.0 {
325                el.y + el.height
326            } else {
327                el.y
328            } as f32;
329            let w = (el.width).abs() as f32;
330            let h = (el.height).abs() as f32;
331
332            self.fill_shape(pixmap, &path, &el.fill, bx, by, w, h, transform, opacity);
333            let (paint, stroke) = stroke_from_style(&el.stroke, opacity);
334            pixmap.stroke_path(&path, &paint, &stroke, *transform, None);
335        }
336    }
337
338    fn draw_diamond(
339        &self,
340        pixmap: &mut Pixmap,
341        el: &ShapeElement,
342        transform: &Transform,
343        opacity: f32,
344    ) {
345        let x = if el.width < 0.0 {
346            el.x + el.width
347        } else {
348            el.x
349        } as f32;
350        let y = if el.height < 0.0 {
351            el.y + el.height
352        } else {
353            el.y
354        } as f32;
355        let w = (el.width).abs() as f32;
356        let h = (el.height).abs() as f32;
357        if w < 0.5 && h < 0.5 {
358            return;
359        }
360
361        if let Some(path) = build_diamond_path(x, y, w, h) {
362            self.fill_shape(pixmap, &path, &el.fill, x, y, w, h, transform, opacity);
363            let (paint, stroke) = stroke_from_style(&el.stroke, opacity);
364            pixmap.stroke_path(&path, &paint, &stroke, *transform, None);
365        }
366    }
367
368    fn draw_line(
369        &self,
370        pixmap: &mut Pixmap,
371        el: &LineElement,
372        transform: &Transform,
373        opacity: f32,
374    ) {
375        if el.points.len() < 2 {
376            return;
377        }
378        if let Some(path) = build_polyline_path(el) {
379            let (paint, stroke) = stroke_from_style(&el.stroke, opacity);
380            pixmap.stroke_path(&path, &paint, &stroke, *transform, None);
381        }
382    }
383
384    fn draw_arrow(
385        &self,
386        pixmap: &mut Pixmap,
387        el: &LineElement,
388        transform: &Transform,
389        opacity: f32,
390    ) {
391        // Draw the line portion
392        self.draw_line(pixmap, el, transform, opacity);
393        if el.points.len() < 2 {
394            return;
395        }
396
397        let color = parse_color(&el.stroke.color, opacity);
398        let mut paint = Paint::default();
399        paint.set_color(color);
400        paint.anti_alias = true;
401        let arrowhead_stroke = Stroke {
402            width: (el.stroke.width as f32) * 0.5,
403            line_cap: LineCap::Round,
404            line_join: LineJoin::Round,
405            ..Stroke::default()
406        };
407
408        // End arrowhead (tip at last point, pointing away from second-to-last)
409        let last = el.points.last().unwrap();
410        let prev = &el.points[el.points.len() - 2];
411        let tip_x = last.x as f32 + el.x as f32;
412        let tip_y = last.y as f32 + el.y as f32;
413        let angle = (last.y as f32 - prev.y as f32).atan2(last.x as f32 - prev.x as f32);
414
415        let left_x = tip_x - ARROWHEAD_LENGTH * (angle - ARROWHEAD_ANGLE).cos();
416        let left_y = tip_y - ARROWHEAD_LENGTH * (angle - ARROWHEAD_ANGLE).sin();
417        let right_x = tip_x - ARROWHEAD_LENGTH * (angle + ARROWHEAD_ANGLE).cos();
418        let right_y = tip_y - ARROWHEAD_LENGTH * (angle + ARROWHEAD_ANGLE).sin();
419
420        let mut pb = PathBuilder::new();
421        pb.move_to(tip_x, tip_y);
422        pb.line_to(left_x, left_y);
423        pb.line_to(right_x, right_y);
424        pb.close();
425        if let Some(path) = pb.finish() {
426            pixmap.fill_path(&path, &paint, FillRule::Winding, *transform, None);
427            pixmap.stroke_path(&path, &paint, &arrowhead_stroke, *transform, None);
428        }
429
430        // Start arrowhead (tip at first point, pointing away from second point)
431        if el.start_arrowhead.is_some() {
432            let first = &el.points[0];
433            let next = &el.points[1];
434            let start_tip_x = first.x as f32 + el.x as f32;
435            let start_tip_y = first.y as f32 + el.y as f32;
436            let start_angle =
437                (first.y as f32 - next.y as f32).atan2(first.x as f32 - next.x as f32);
438
439            let start_left_x =
440                start_tip_x - ARROWHEAD_LENGTH * (start_angle - ARROWHEAD_ANGLE).cos();
441            let start_left_y =
442                start_tip_y - ARROWHEAD_LENGTH * (start_angle - ARROWHEAD_ANGLE).sin();
443            let start_right_x =
444                start_tip_x - ARROWHEAD_LENGTH * (start_angle + ARROWHEAD_ANGLE).cos();
445            let start_right_y =
446                start_tip_y - ARROWHEAD_LENGTH * (start_angle + ARROWHEAD_ANGLE).sin();
447
448            let mut pb = PathBuilder::new();
449            pb.move_to(start_tip_x, start_tip_y);
450            pb.line_to(start_left_x, start_left_y);
451            pb.line_to(start_right_x, start_right_y);
452            pb.close();
453            if let Some(path) = pb.finish() {
454                pixmap.fill_path(&path, &paint, FillRule::Winding, *transform, None);
455                pixmap.stroke_path(&path, &paint, &arrowhead_stroke, *transform, None);
456            }
457        }
458    }
459
460    fn draw_freedraw(
461        &self,
462        pixmap: &mut Pixmap,
463        el: &FreeDrawElement,
464        transform: &Transform,
465        opacity: f32,
466    ) {
467        if el.points.len() < 2 {
468            return;
469        }
470        if let Some(path) = build_freedraw_path(el) {
471            let (paint, stroke) = stroke_from_style(&el.stroke, opacity);
472            pixmap.stroke_path(&path, &paint, &stroke, *transform, None);
473        }
474    }
475
476    /// TODO: Text is rendered as a colored placeholder rectangle. Full glyph
477    /// rendering via cosmic-text will be added in a follow-up phase.
478    fn draw_text(
479        &self,
480        pixmap: &mut Pixmap,
481        el: &TextElement,
482        transform: &Transform,
483        opacity: f32,
484    ) {
485        let size = el.font.size as f32;
486        let lines: Vec<&str> = el.text.split('\n').collect();
487        let max_chars = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f32;
488        let w = (max_chars * size * TEXT_CHAR_WIDTH_FACTOR).max(size * TEXT_MIN_CHARS);
489        let h = (lines.len() as f32 * size * TEXT_LINE_HEIGHT_FACTOR)
490            .max(size * TEXT_LINE_HEIGHT_FACTOR);
491        let x = el.x as f32;
492        let y = el.y as f32;
493
494        // Draw placeholder rectangle with text color at reduced opacity
495        let color = parse_color(&el.stroke.color, opacity * 0.3);
496        let rect = Rect::from_xywh(x, y, w.max(1.0), h.max(1.0));
497        if let Some(rect) = rect {
498            let mut paint = Paint::default();
499            paint.set_color(color);
500            paint.anti_alias = true;
501            pixmap.fill_rect(rect, &paint, *transform, None);
502
503            // Dashed border to indicate placeholder
504            let border_color = parse_color(&el.stroke.color, opacity * 0.5);
505            let mut border_paint = Paint::default();
506            border_paint.set_color(border_color);
507            border_paint.anti_alias = true;
508            let stroke = Stroke {
509                width: 1.0,
510                dash: StrokeDash::new(vec![3.0, 3.0], 0.0),
511                ..Stroke::default()
512            };
513            let mut pb = PathBuilder::new();
514            pb.push_rect(rect);
515            if let Some(path) = pb.finish() {
516                pixmap.stroke_path(&path, &border_paint, &stroke, *transform, None);
517            }
518        }
519    }
520
521    // ── Fill patterns ───────────────────────────────────────────────
522
523    #[allow(clippy::too_many_arguments)]
524    fn fill_shape(
525        &self,
526        pixmap: &mut Pixmap,
527        clip_path: &Path,
528        fill: &FillStyle,
529        x: f32,
530        y: f32,
531        w: f32,
532        h: f32,
533        transform: &Transform,
534        opacity: f32,
535    ) {
536        match fill.style {
537            FillType::None => {}
538            FillType::Solid => {
539                let color = parse_color(&fill.color, opacity);
540                let mut paint = Paint::default();
541                paint.set_color(color);
542                paint.anti_alias = true;
543                pixmap.fill_path(clip_path, &paint, FillRule::Winding, *transform, None);
544            }
545            FillType::Hachure => {
546                self.draw_hachure_fill(
547                    pixmap,
548                    clip_path,
549                    &fill.color,
550                    fill.gap as f32,
551                    fill.angle as f32,
552                    x,
553                    y,
554                    w,
555                    h,
556                    transform,
557                    opacity,
558                );
559            }
560            FillType::CrossHatch => {
561                self.draw_hachure_fill(
562                    pixmap,
563                    clip_path,
564                    &fill.color,
565                    fill.gap as f32,
566                    fill.angle as f32,
567                    x,
568                    y,
569                    w,
570                    h,
571                    transform,
572                    opacity,
573                );
574                // Second pass perpendicular
575                self.draw_hachure_fill(
576                    pixmap,
577                    clip_path,
578                    &fill.color,
579                    fill.gap as f32,
580                    fill.angle as f32 + std::f32::consts::FRAC_PI_2,
581                    x,
582                    y,
583                    w,
584                    h,
585                    transform,
586                    opacity,
587                );
588            }
589        }
590    }
591
592    /// Draw hachure (diagonal parallel lines) clipped to the shape path.
593    #[allow(clippy::too_many_arguments)]
594    fn draw_hachure_fill(
595        &self,
596        pixmap: &mut Pixmap,
597        clip_path: &Path,
598        color: &str,
599        gap: f32,
600        angle: f32,
601        x: f32,
602        y: f32,
603        w: f32,
604        h: f32,
605        transform: &Transform,
606        opacity: f32,
607    ) {
608        // Build a clip mask from the shape
609        let mut clip_mask = match Mask::new(pixmap.width(), pixmap.height()) {
610            Some(m) => m,
611            None => return,
612        };
613        clip_mask.fill_path(clip_path, FillRule::Winding, true, *transform);
614
615        let parsed_color = parse_color(color, opacity * HACHURE_ALPHA);
616        let mut paint = Paint::default();
617        paint.set_color(parsed_color);
618        paint.anti_alias = true;
619
620        let stroke = Stroke {
621            width: HACHURE_LINE_WIDTH,
622            line_cap: LineCap::Round,
623            ..Stroke::default()
624        };
625
626        // Generate hachure lines rotated around shape center
627        let cx = x + w / 2.0;
628        let cy = y + h / 2.0;
629        let diag = (w * w + h * h).sqrt();
630
631        let cos_a = angle.cos();
632        let sin_a = angle.sin();
633
634        let mut d = -diag;
635        while d < diag {
636            // Line endpoints rotated around (cx, cy)
637            let x1 = cx + d * cos_a - (-diag) * sin_a;
638            let y1 = cy + d * sin_a + (-diag) * cos_a;
639            let x2 = cx + d * cos_a - diag * sin_a;
640            let y2 = cy + d * sin_a + diag * cos_a;
641
642            let mut pb = PathBuilder::new();
643            pb.move_to(x1, y1);
644            pb.line_to(x2, y2);
645            if let Some(line_path) = pb.finish() {
646                pixmap.stroke_path(&line_path, &paint, &stroke, *transform, Some(&clip_mask));
647            }
648            d += gap;
649        }
650    }
651
652    // ── Grid ────────────────────────────────────────────────────────
653
654    fn draw_grid(&self, pixmap: &mut Pixmap, viewport: &ViewState) {
655        let zoom = viewport.zoom as f32 * self.config.pixel_ratio;
656        let gs = GRID_SIZE * zoom;
657        if gs < GRID_MIN_SCREEN_PX {
658            return;
659        }
660
661        let w = pixmap.width() as f32;
662        let h = pixmap.height() as f32;
663        let off_x = (viewport.scroll_x as f32 * self.config.pixel_ratio) % gs;
664        let off_y = (viewport.scroll_y as f32 * self.config.pixel_ratio) % gs;
665
666        let color = Color::from_rgba(
667            GRID_R as f32 / 255.0,
668            GRID_G as f32 / 255.0,
669            GRID_B as f32 / 255.0,
670            GRID_ALPHA,
671        )
672        .unwrap_or(Color::TRANSPARENT);
673        let mut paint = Paint::default();
674        paint.set_color(color);
675        paint.anti_alias = false;
676
677        let stroke = Stroke {
678            width: 1.0,
679            ..Stroke::default()
680        };
681        let identity = Transform::identity();
682
683        // Vertical lines
684        let mut x = off_x;
685        while x < w {
686            let mut pb = PathBuilder::new();
687            pb.move_to(x, 0.0);
688            pb.line_to(x, h);
689            if let Some(path) = pb.finish() {
690                pixmap.stroke_path(&path, &paint, &stroke, identity, None);
691            }
692            x += gs;
693        }
694
695        // Horizontal lines
696        let mut y = off_y;
697        while y < h {
698            let mut pb = PathBuilder::new();
699            pb.move_to(0.0, y);
700            pb.line_to(w, y);
701            if let Some(path) = pb.finish() {
702                pixmap.stroke_path(&path, &paint, &stroke, identity, None);
703            }
704            y += gs;
705        }
706    }
707
708    // ── Selection visuals ───────────────────────────────────────────
709
710    fn draw_selection_box(&self, pixmap: &mut Pixmap, el: &Element, viewport: &ViewState) {
711        let b = el.bounds();
712        let scale = viewport.zoom as f32 * self.config.pixel_ratio;
713        let vt = viewport_transform(viewport, self.config.pixel_ratio);
714
715        let pad = SELECTION_PAD;
716        let sx = b.x as f32 - pad;
717        let sy = b.y as f32 - pad;
718        let sw = b.width as f32 + pad * 2.0;
719        let sh = b.height as f32 + pad * 2.0;
720
721        if let Some(rect) = Rect::from_xywh(sx, sy, sw.max(1.0), sh.max(1.0)) {
722            // Selection fill
723            let fill_color = Color::from_rgba(
724                ACCENT_R as f32 / 255.0,
725                ACCENT_G as f32 / 255.0,
726                ACCENT_B as f32 / 255.0,
727                SELECTION_FILL_ALPHA,
728            )
729            .unwrap_or(Color::TRANSPARENT);
730            let mut paint = Paint::default();
731            paint.set_color(fill_color);
732            pixmap.fill_rect(rect, &paint, vt, None);
733
734            // Dashed border
735            let accent = Color::from_rgba8(ACCENT_R, ACCENT_G, ACCENT_B, 255);
736            let mut border_paint = Paint::default();
737            border_paint.set_color(accent);
738            border_paint.anti_alias = true;
739            let dash_len = SELECTION_DASH_LEN / scale;
740            let stroke = Stroke {
741                width: 1.5 / scale,
742                dash: StrokeDash::new(vec![dash_len, dash_len], 0.0),
743                ..Stroke::default()
744            };
745            let mut pb = PathBuilder::new();
746            pb.push_rect(rect);
747            if let Some(path) = pb.finish() {
748                pixmap.stroke_path(&path, &border_paint, &stroke, vt, None);
749            }
750
751            // Resize handles at corners
752            let handle_fill = {
753                let c = Color::from_rgba8(HANDLE_FILL_R, HANDLE_FILL_G, HANDLE_FILL_B, 255);
754                let mut p = Paint::default();
755                p.set_color(c);
756                p.anti_alias = true;
757                p
758            };
759            let mut handle_stroke_paint = Paint::default();
760            handle_stroke_paint.set_color(accent);
761            handle_stroke_paint.anti_alias = true;
762            let handle_stroke = Stroke {
763                width: 1.5 / scale,
764                ..Stroke::default()
765            };
766            let hs = HANDLE_RADIUS / scale;
767
768            let corners = [(sx, sy), (sx + sw, sy), (sx, sy + sh), (sx + sw, sy + sh)];
769            for (hx, hy) in &corners {
770                if let Some(handle_path) = build_circle_path(*hx, *hy, hs) {
771                    pixmap.fill_path(&handle_path, &handle_fill, FillRule::Winding, vt, None);
772                    pixmap.stroke_path(
773                        &handle_path,
774                        &handle_stroke_paint,
775                        &handle_stroke,
776                        vt,
777                        None,
778                    );
779                }
780            }
781        }
782    }
783
784    fn draw_rubber_band(&self, pixmap: &mut Pixmap, sb: &Bounds) {
785        let pr = self.config.pixel_ratio;
786        let x = sb.x as f32 * pr;
787        let y = sb.y as f32 * pr;
788        let w = (sb.width as f32 * pr).abs().max(1.0);
789        let h = (sb.height as f32 * pr).abs().max(1.0);
790
791        if let Some(rect) = Rect::from_xywh(x, y, w, h) {
792            let identity = Transform::identity();
793
794            // Fill
795            let fill_color = Color::from_rgba(
796                ACCENT_R as f32 / 255.0,
797                ACCENT_G as f32 / 255.0,
798                ACCENT_B as f32 / 255.0,
799                SELECTION_FILL_ALPHA,
800            )
801            .unwrap_or(Color::TRANSPARENT);
802            let mut fill_paint = Paint::default();
803            fill_paint.set_color(fill_color);
804            pixmap.fill_rect(rect, &fill_paint, identity, None);
805
806            // Dashed stroke
807            let accent = Color::from_rgba8(ACCENT_R, ACCENT_G, ACCENT_B, 255);
808            let mut stroke_paint = Paint::default();
809            stroke_paint.set_color(accent);
810            stroke_paint.anti_alias = true;
811            let stroke = Stroke {
812                width: 1.0 * pr,
813                dash: StrokeDash::new(vec![SELECTION_DASH_LEN * pr, SELECTION_DASH_LEN * pr], 0.0),
814                ..Stroke::default()
815            };
816            let mut pb = PathBuilder::new();
817            pb.push_rect(rect);
818            if let Some(path) = pb.finish() {
819                pixmap.stroke_path(&path, &stroke_paint, &stroke, identity, None);
820            }
821        }
822    }
823
824    // ── Hit testing internals ───────────────────────────────────────
825
826    fn hit_test_element(&self, el: &Element, wx: f32, wy: f32) -> bool {
827        match el {
828            Element::Rectangle(e) => hit_test_shape_bounds(e, wx, wy),
829            Element::Ellipse(e) => hit_test_ellipse(e, wx, wy),
830            Element::Diamond(e) => hit_test_diamond(e, wx, wy),
831            Element::Line(e) | Element::Arrow(e) => hit_test_line(e, wx, wy),
832            Element::FreeDraw(e) => hit_test_freedraw_bounds(e, wx, wy),
833            Element::Text(e) => hit_test_text_bounds(e, wx, wy),
834        }
835    }
836}
837
838// ── Path builders ───────────────────────────────────────────────────────
839
840fn build_rounded_rect_path(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Option<Path> {
841    let r = radius.min(w / 2.0).min(h / 2.0);
842    let mut pb = PathBuilder::new();
843
844    if r <= 0.5 {
845        let rect = Rect::from_xywh(x, y, w, h)?;
846        pb.push_rect(rect);
847    } else {
848        // Top edge
849        pb.move_to(x + r, y);
850        pb.line_to(x + w - r, y);
851        // Top-right corner
852        pb.quad_to(x + w, y, x + w, y + r);
853        // Right edge
854        pb.line_to(x + w, y + h - r);
855        // Bottom-right corner
856        pb.quad_to(x + w, y + h, x + w - r, y + h);
857        // Bottom edge
858        pb.line_to(x + r, y + h);
859        // Bottom-left corner
860        pb.quad_to(x, y + h, x, y + h - r);
861        // Left edge
862        pb.line_to(x, y + r);
863        // Top-left corner
864        pb.quad_to(x, y, x + r, y);
865        pb.close();
866    }
867    pb.finish()
868}
869
870fn build_ellipse_path(cx: f32, cy: f32, rx: f32, ry: f32) -> Option<Path> {
871    // Approximate ellipse with 4 cubic bezier segments
872    const KAPPA: f32 = 0.552_284_8;
873    let kx = KAPPA * rx;
874    let ky = KAPPA * ry;
875
876    let mut pb = PathBuilder::new();
877    pb.move_to(cx + rx, cy);
878    pb.cubic_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry);
879    pb.cubic_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy);
880    pb.cubic_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry);
881    pb.cubic_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy);
882    pb.close();
883    pb.finish()
884}
885
886fn build_diamond_path(x: f32, y: f32, w: f32, h: f32) -> Option<Path> {
887    let cx = x + w / 2.0;
888    let cy = y + h / 2.0;
889    let r = CORNER_RADIUS.min(w / 6.0).min(h / 6.0);
890
891    let top = (cx, y);
892    let right = (x + w, cy);
893    let bottom = (cx, y + h);
894    let left = (x, cy);
895
896    let dist_lt = dist_f32(left, top);
897    let dist_tr = dist_f32(top, right);
898    let dist_rb = dist_f32(right, bottom);
899    let dist_bl = dist_f32(bottom, left);
900
901    if dist_lt < 0.01 || dist_tr < 0.01 || dist_rb < 0.01 || dist_bl < 0.01 {
902        return None;
903    }
904
905    let t_lt = (r / dist_lt).min(0.5);
906    let t_tr = (r / dist_tr).min(0.5);
907    let t_rb = (r / dist_rb).min(0.5);
908    let t_bl = (r / dist_bl).min(0.5);
909
910    let mut pb = PathBuilder::new();
911
912    // Start between left and top
913    pb.move_to(lerp_f32(left.0, top.0, t_lt), lerp_f32(left.1, top.1, t_lt));
914    // Round corner at top
915    pb.quad_to(
916        top.0,
917        top.1,
918        lerp_f32(top.0, right.0, t_tr),
919        lerp_f32(top.1, right.1, t_tr),
920    );
921    // Round corner at right
922    pb.quad_to(
923        right.0,
924        right.1,
925        lerp_f32(right.0, bottom.0, t_rb),
926        lerp_f32(right.1, bottom.1, t_rb),
927    );
928    // Round corner at bottom
929    pb.quad_to(
930        bottom.0,
931        bottom.1,
932        lerp_f32(bottom.0, left.0, t_bl),
933        lerp_f32(bottom.1, left.1, t_bl),
934    );
935    // Round corner at left
936    pb.quad_to(
937        left.0,
938        left.1,
939        lerp_f32(left.0, top.0, t_lt),
940        lerp_f32(left.1, top.1, t_lt),
941    );
942    pb.close();
943    pb.finish()
944}
945
946fn build_polyline_path(el: &LineElement) -> Option<Path> {
947    let mut pb = PathBuilder::new();
948    let first = el.points.first()?;
949    pb.move_to(first.x as f32 + el.x as f32, first.y as f32 + el.y as f32);
950    for p in &el.points[1..] {
951        pb.line_to(p.x as f32 + el.x as f32, p.y as f32 + el.y as f32);
952    }
953    pb.finish()
954}
955
956fn build_freedraw_path(el: &FreeDrawElement) -> Option<Path> {
957    let mut pb = PathBuilder::new();
958    let ox = el.x as f32;
959    let oy = el.y as f32;
960    let pts = &el.points;
961
962    pb.move_to(pts[0].x as f32 + ox, pts[0].y as f32 + oy);
963    // Smooth bezier interpolation through midpoints
964    for i in 1..pts.len().saturating_sub(1) {
965        let xc = (pts[i].x as f32 + pts[i + 1].x as f32) / 2.0 + ox;
966        let yc = (pts[i].y as f32 + pts[i + 1].y as f32) / 2.0 + oy;
967        pb.quad_to(pts[i].x as f32 + ox, pts[i].y as f32 + oy, xc, yc);
968    }
969    let last = pts.last()?;
970    pb.line_to(last.x as f32 + ox, last.y as f32 + oy);
971    pb.finish()
972}
973
974fn build_circle_path(cx: f32, cy: f32, r: f32) -> Option<Path> {
975    build_ellipse_path(cx, cy, r, r)
976}
977
978// ── Color parsing ───────────────────────────────────────────────────────
979
980/// Parse a CSS hex color string (e.g. "#3b82f6") into a tiny-skia Color.
981/// Falls back to default stroke color on parse failure.
982fn parse_color(hex: &str, opacity: f32) -> Color {
983    let hex = hex.trim().trim_start_matches('#');
984    let (r, g, b) = match hex.len() {
985        6 => {
986            let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(DEFAULT_STROKE_R);
987            let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(DEFAULT_STROKE_G);
988            let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(DEFAULT_STROKE_B);
989            (r, g, b)
990        }
991        3 => {
992            let r = u8::from_str_radix(&hex[0..1], 16)
993                .map(|v| v * 17)
994                .unwrap_or(DEFAULT_STROKE_R);
995            let g = u8::from_str_radix(&hex[1..2], 16)
996                .map(|v| v * 17)
997                .unwrap_or(DEFAULT_STROKE_G);
998            let b = u8::from_str_radix(&hex[2..3], 16)
999                .map(|v| v * 17)
1000                .unwrap_or(DEFAULT_STROKE_B);
1001            (r, g, b)
1002        }
1003        _ => (DEFAULT_STROKE_R, DEFAULT_STROKE_G, DEFAULT_STROKE_B),
1004    };
1005    let a = (opacity * 255.0).clamp(0.0, 255.0) as u8;
1006    Color::from_rgba8(r, g, b, a)
1007}
1008
1009// ── Stroke helper ───────────────────────────────────────────────────────
1010
1011fn stroke_from_style(style: &StrokeStyle, opacity: f32) -> (Paint<'static>, Stroke) {
1012    let color = parse_color(&style.color, opacity);
1013    let mut paint = Paint::default();
1014    paint.set_color(color);
1015    paint.anti_alias = true;
1016
1017    let dash = if style.dash.is_empty() {
1018        None
1019    } else {
1020        let dash_vals: Vec<f32> = style.dash.iter().map(|d| *d as f32).collect();
1021        StrokeDash::new(dash_vals, 0.0)
1022    };
1023
1024    let stroke = Stroke {
1025        width: style.width as f32,
1026        line_cap: LineCap::Round,
1027        line_join: LineJoin::Round,
1028        dash,
1029        ..Stroke::default()
1030    };
1031    (paint, stroke)
1032}
1033
1034// ── Viewport transform ──────────────────────────────────────────────────
1035
1036/// Build the viewport transform: screen = world * zoom + scroll.
1037/// Incorporates pixel_ratio scaling.
1038fn viewport_transform(viewport: &ViewState, pixel_ratio: f32) -> Transform {
1039    let pr = pixel_ratio;
1040    let zoom = viewport.zoom as f32 * pr;
1041    let tx = viewport.scroll_x as f32 * pr;
1042    let ty = viewport.scroll_y as f32 * pr;
1043    Transform::from_row(zoom, 0.0, 0.0, zoom, tx, ty)
1044}
1045
1046/// Convert screen coordinates to world coordinates.
1047fn screen_to_world(viewport: &ViewState, sx: f32, sy: f32) -> (f32, f32) {
1048    let wx = (sx - viewport.scroll_x as f32) / viewport.zoom as f32;
1049    let wy = (sy - viewport.scroll_y as f32) / viewport.zoom as f32;
1050    (wx, wy)
1051}
1052
1053// ── Hit testing helpers ─────────────────────────────────────────────────
1054
1055fn hit_test_shape_bounds(e: &ShapeElement, wx: f32, wy: f32) -> bool {
1056    let x = if e.width < 0.0 { e.x + e.width } else { e.x } as f32;
1057    let y = if e.height < 0.0 { e.y + e.height } else { e.y } as f32;
1058    let w = (e.width).abs() as f32;
1059    let h = (e.height).abs() as f32;
1060    wx >= x - HIT_TEST_PAD
1061        && wx <= x + w + HIT_TEST_PAD
1062        && wy >= y - HIT_TEST_PAD
1063        && wy <= y + h + HIT_TEST_PAD
1064}
1065
1066fn hit_test_ellipse(e: &ShapeElement, wx: f32, wy: f32) -> bool {
1067    let cx = e.x as f32 + e.width as f32 / 2.0;
1068    let cy = e.y as f32 + e.height as f32 / 2.0;
1069    let rx = (e.width as f32).abs() / 2.0 + HIT_TEST_PAD;
1070    let ry = (e.height as f32).abs() / 2.0 + HIT_TEST_PAD;
1071    if rx < 0.01 || ry < 0.01 {
1072        return false;
1073    }
1074    let dx = wx - cx;
1075    let dy = wy - cy;
1076    (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1.0
1077}
1078
1079fn hit_test_diamond(e: &ShapeElement, wx: f32, wy: f32) -> bool {
1080    let x = if e.width < 0.0 { e.x + e.width } else { e.x } as f32;
1081    let y = if e.height < 0.0 { e.y + e.height } else { e.y } as f32;
1082    let w = (e.width).abs() as f32;
1083    let h = (e.height).abs() as f32;
1084    let cx = x + w / 2.0;
1085    let cy = y + h / 2.0;
1086
1087    // Manhattan distance normalized to diamond half-widths
1088    let dx = (wx - cx).abs();
1089    let dy = (wy - cy).abs();
1090    let hw = w / 2.0 + HIT_TEST_PAD;
1091    let hh = h / 2.0 + HIT_TEST_PAD;
1092
1093    if hw < 0.01 || hh < 0.01 {
1094        return false;
1095    }
1096    dx / hw + dy / hh <= 1.0
1097}
1098
1099fn hit_test_line(e: &LineElement, wx: f32, wy: f32) -> bool {
1100    let ox = e.x as f32;
1101    let oy = e.y as f32;
1102    for pair in e.points.windows(2) {
1103        let ax = pair[0].x as f32 + ox;
1104        let ay = pair[0].y as f32 + oy;
1105        let bx = pair[1].x as f32 + ox;
1106        let by = pair[1].y as f32 + oy;
1107        if point_to_segment_distance(wx, wy, ax, ay, bx, by) < LINE_HIT_TOLERANCE {
1108            return true;
1109        }
1110    }
1111    false
1112}
1113
1114fn hit_test_freedraw_bounds(e: &FreeDrawElement, wx: f32, wy: f32) -> bool {
1115    let b = crate::Element::FreeDraw(e.clone()).bounds();
1116    wx >= b.x as f32 - HIT_TEST_PAD
1117        && wx <= (b.x + b.width) as f32 + HIT_TEST_PAD
1118        && wy >= b.y as f32 - HIT_TEST_PAD
1119        && wy <= (b.y + b.height) as f32 + HIT_TEST_PAD
1120}
1121
1122fn hit_test_text_bounds(e: &TextElement, wx: f32, wy: f32) -> bool {
1123    let b = crate::Element::Text(e.clone()).bounds();
1124    wx >= b.x as f32 - HIT_TEST_PAD
1125        && wx <= (b.x + b.width) as f32 + HIT_TEST_PAD
1126        && wy >= b.y as f32 - HIT_TEST_PAD
1127        && wy <= (b.y + b.height) as f32 + HIT_TEST_PAD
1128}
1129
1130/// Distance from point (px, py) to line segment (ax,ay)-(bx,by).
1131fn point_to_segment_distance(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
1132    let dx = bx - ax;
1133    let dy = by - ay;
1134    let len_sq = dx * dx + dy * dy;
1135    if len_sq < 0.0001 {
1136        return ((px - ax).powi(2) + (py - ay).powi(2)).sqrt();
1137    }
1138    let t = ((px - ax) * dx + (py - ay) * dy) / len_sq;
1139    let t = t.clamp(0.0, 1.0);
1140    let proj_x = ax + t * dx;
1141    let proj_y = ay + t * dy;
1142    ((px - proj_x).powi(2) + (py - proj_y).powi(2)).sqrt()
1143}
1144
1145// ── Geometry helpers ────────────────────────────────────────────────────
1146
1147fn lerp_f32(a: f32, b: f32, t: f32) -> f32 {
1148    a + (b - a) * t
1149}
1150
1151fn dist_f32(a: (f32, f32), b: (f32, f32)) -> f32 {
1152    ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt()
1153}
1154
1155/// Check if element has a group_id (bound text elements skip selection highlight).
1156fn has_group_id(el: &Element) -> bool {
1157    match el {
1158        Element::Rectangle(e) | Element::Ellipse(e) | Element::Diamond(e) => e.group_id.is_some(),
1159        Element::Line(e) | Element::Arrow(e) => e.group_id.is_some(),
1160        Element::FreeDraw(e) => e.group_id.is_some(),
1161        Element::Text(e) => e.group_id.is_some(),
1162    }
1163}
1164
1165/// Convert a Bounds to a tiny_skia Rect.
1166fn to_skia_rect_from_bounds(b: &Bounds) -> Option<Rect> {
1167    Rect::from_xywh(
1168        b.x as f32,
1169        b.y as f32,
1170        (b.width as f32).max(0.1),
1171        (b.height as f32).max(0.1),
1172    )
1173}
1174
1175/// Get element bounds as a tiny_skia Rect.
1176fn element_bounds_f32(el: &Element) -> Option<Rect> {
1177    let b = el.bounds();
1178    Rect::from_xywh(
1179        b.x as f32,
1180        b.y as f32,
1181        (b.width as f32).max(0.1),
1182        (b.height as f32).max(0.1),
1183    )
1184}
1185
1186/// Check if two rects intersect.
1187fn rects_intersect(a: &Rect, b: &Rect) -> bool {
1188    a.x() < b.x() + b.width()
1189        && a.x() + a.width() > b.x()
1190        && a.y() < b.y() + b.height()
1191        && a.y() + a.height() > b.y()
1192}
1193
1194// ── Tests ───────────────────────────────────────────────────────────────
1195
1196#[cfg(test)]
1197mod tests {
1198    use super::*;
1199    use crate::element::{FreeDrawElement, LineElement, ShapeElement, TextElement};
1200    use crate::point::Point;
1201    use crate::style::FillStyle;
1202
1203    fn make_doc() -> Document {
1204        Document::new("test".to_string())
1205    }
1206
1207    fn default_viewport() -> ViewState {
1208        ViewState::default()
1209    }
1210
1211    fn small_config() -> RenderConfig {
1212        RenderConfig {
1213            width: 200,
1214            height: 200,
1215            ..RenderConfig::default()
1216        }
1217    }
1218
1219    // ── render() produces a non-empty pixmap ────────────────────────
1220
1221    #[test]
1222    fn test_render_empty_document_produces_background() {
1223        let mut config = small_config();
1224        config.show_grid = false; // disable grid so pixels are pure background
1225        let renderer = Renderer::new(config);
1226        let doc = make_doc();
1227        let vp = default_viewport();
1228        let pixmap = renderer.render(&doc, &vp, &[], None);
1229        assert_eq!(pixmap.width(), 200);
1230        assert_eq!(pixmap.height(), 200);
1231
1232        // Check that pixels match background color (no grid overlay)
1233        let data = pixmap.data();
1234        assert_eq!(data[0], BG_R);
1235        assert_eq!(data[1], BG_G);
1236        assert_eq!(data[2], BG_B);
1237        assert_eq!(data[3], 255);
1238    }
1239
1240    #[test]
1241    fn test_render_with_elements_not_empty() {
1242        let renderer = Renderer::new(small_config());
1243        let mut doc = make_doc();
1244        doc.add_element(Element::Rectangle(ShapeElement::new(
1245            "r1".into(),
1246            10.0,
1247            10.0,
1248            80.0,
1249            60.0,
1250        )));
1251        let vp = default_viewport();
1252        let pixmap = renderer.render(&doc, &vp, &[], None);
1253        assert!(pixmap.width() > 0);
1254        assert!(pixmap.height() > 0);
1255        // The pixmap data should differ from a pure background fill
1256        let bg_renderer = Renderer::new(small_config());
1257        let bg_pixmap = bg_renderer.render(&make_doc(), &vp, &[], None);
1258        assert_ne!(pixmap.data(), bg_pixmap.data());
1259    }
1260
1261    // ── Each element type renders without panicking ─────────────────
1262
1263    #[test]
1264    fn test_render_rectangle() {
1265        let renderer = Renderer::new(small_config());
1266        let mut doc = make_doc();
1267        doc.add_element(Element::Rectangle(ShapeElement::new(
1268            "r1".into(),
1269            5.0,
1270            5.0,
1271            50.0,
1272            40.0,
1273        )));
1274        let _ = renderer.render(&doc, &default_viewport(), &[], None);
1275    }
1276
1277    #[test]
1278    fn test_render_ellipse() {
1279        let renderer = Renderer::new(small_config());
1280        let mut doc = make_doc();
1281        doc.add_element(Element::Ellipse(ShapeElement::new(
1282            "e1".into(),
1283            10.0,
1284            10.0,
1285            60.0,
1286            40.0,
1287        )));
1288        let _ = renderer.render(&doc, &default_viewport(), &[], None);
1289    }
1290
1291    #[test]
1292    fn test_render_diamond() {
1293        let renderer = Renderer::new(small_config());
1294        let mut doc = make_doc();
1295        doc.add_element(Element::Diamond(ShapeElement::new(
1296            "d1".into(),
1297            10.0,
1298            10.0,
1299            60.0,
1300            60.0,
1301        )));
1302        let _ = renderer.render(&doc, &default_viewport(), &[], None);
1303    }
1304
1305    #[test]
1306    fn test_render_line() {
1307        let renderer = Renderer::new(small_config());
1308        let mut doc = make_doc();
1309        doc.add_element(Element::Line(LineElement::new(
1310            "l1".into(),
1311            0.0,
1312            0.0,
1313            vec![Point::new(10.0, 10.0), Point::new(90.0, 90.0)],
1314        )));
1315        let _ = renderer.render(&doc, &default_viewport(), &[], None);
1316    }
1317
1318    #[test]
1319    fn test_render_arrow() {
1320        let renderer = Renderer::new(small_config());
1321        let mut doc = make_doc();
1322        doc.add_element(Element::Arrow(LineElement::new(
1323            "a1".into(),
1324            0.0,
1325            0.0,
1326            vec![Point::new(10.0, 10.0), Point::new(90.0, 50.0)],
1327        )));
1328        let _ = renderer.render(&doc, &default_viewport(), &[], None);
1329    }
1330
1331    #[test]
1332    fn test_render_freedraw() {
1333        let renderer = Renderer::new(small_config());
1334        let mut doc = make_doc();
1335        doc.add_element(Element::FreeDraw(FreeDrawElement::new(
1336            "fd1".into(),
1337            0.0,
1338            0.0,
1339            vec![
1340                Point::new(10.0, 10.0),
1341                Point::new(20.0, 30.0),
1342                Point::new(40.0, 20.0),
1343                Point::new(60.0, 50.0),
1344            ],
1345        )));
1346        let _ = renderer.render(&doc, &default_viewport(), &[], None);
1347    }
1348
1349    #[test]
1350    fn test_render_text_placeholder() {
1351        let renderer = Renderer::new(small_config());
1352        let mut doc = make_doc();
1353        doc.add_element(Element::Text(TextElement::new(
1354            "t1".into(),
1355            10.0,
1356            10.0,
1357            "Hello world".into(),
1358        )));
1359        let _ = renderer.render(&doc, &default_viewport(), &[], None);
1360    }
1361
1362    // ── Fill patterns don't panic ───────────────────────────────────
1363
1364    #[test]
1365    fn test_render_solid_fill() {
1366        let renderer = Renderer::new(small_config());
1367        let mut doc = make_doc();
1368        let mut rect = ShapeElement::new("r1".into(), 10.0, 10.0, 80.0, 60.0);
1369        rect.fill = FillStyle {
1370            style: FillType::Solid,
1371            ..FillStyle::default()
1372        };
1373        doc.add_element(Element::Rectangle(rect));
1374        let _ = renderer.render(&doc, &default_viewport(), &[], None);
1375    }
1376
1377    #[test]
1378    fn test_render_hachure_fill() {
1379        let renderer = Renderer::new(small_config());
1380        let mut doc = make_doc();
1381        let mut rect = ShapeElement::new("r1".into(), 10.0, 10.0, 80.0, 60.0);
1382        rect.fill = FillStyle {
1383            style: FillType::Hachure,
1384            ..FillStyle::default()
1385        };
1386        doc.add_element(Element::Rectangle(rect));
1387        let _ = renderer.render(&doc, &default_viewport(), &[], None);
1388    }
1389
1390    #[test]
1391    fn test_render_crosshatch_fill() {
1392        let renderer = Renderer::new(small_config());
1393        let mut doc = make_doc();
1394        let mut rect = ShapeElement::new("r1".into(), 10.0, 10.0, 80.0, 60.0);
1395        rect.fill = FillStyle {
1396            style: FillType::CrossHatch,
1397            ..FillStyle::default()
1398        };
1399        doc.add_element(Element::Rectangle(rect));
1400        let _ = renderer.render(&doc, &default_viewport(), &[], None);
1401    }
1402
1403    // ── Hit testing ─────────────────────────────────────────────────
1404
1405    #[test]
1406    fn test_hit_test_rectangle() {
1407        let renderer = Renderer::new(small_config());
1408        let mut doc = make_doc();
1409        doc.add_element(Element::Rectangle(ShapeElement::new(
1410            "r1".into(),
1411            50.0,
1412            50.0,
1413            100.0,
1414            80.0,
1415        )));
1416        let vp = default_viewport();
1417
1418        // Inside
1419        let hit = renderer.hit_test(&doc, &vp, 80.0, 80.0);
1420        assert_eq!(hit, Some("r1".into()));
1421
1422        // Outside
1423        let miss = renderer.hit_test(&doc, &vp, 5.0, 5.0);
1424        assert!(miss.is_none());
1425    }
1426
1427    #[test]
1428    fn test_hit_test_ellipse() {
1429        let renderer = Renderer::new(small_config());
1430        let mut doc = make_doc();
1431        doc.add_element(Element::Ellipse(ShapeElement::new(
1432            "e1".into(),
1433            50.0,
1434            50.0,
1435            100.0,
1436            60.0,
1437        )));
1438        let vp = default_viewport();
1439
1440        // Center
1441        let hit = renderer.hit_test(&doc, &vp, 100.0, 80.0);
1442        assert_eq!(hit, Some("e1".into()));
1443    }
1444
1445    #[test]
1446    fn test_hit_test_line() {
1447        let renderer = Renderer::new(small_config());
1448        let mut doc = make_doc();
1449        doc.add_element(Element::Line(LineElement::new(
1450            "l1".into(),
1451            0.0,
1452            0.0,
1453            vec![Point::new(10.0, 10.0), Point::new(100.0, 100.0)],
1454        )));
1455        let vp = default_viewport();
1456
1457        // Near the line (midpoint)
1458        let hit = renderer.hit_test(&doc, &vp, 55.0, 55.0);
1459        assert_eq!(hit, Some("l1".into()));
1460
1461        // Far from line
1462        let miss = renderer.hit_test(&doc, &vp, 10.0, 100.0);
1463        assert!(miss.is_none());
1464    }
1465
1466    #[test]
1467    fn test_hit_test_returns_topmost() {
1468        let renderer = Renderer::new(small_config());
1469        let mut doc = make_doc();
1470        doc.add_element(Element::Rectangle(ShapeElement::new(
1471            "r_bottom".into(),
1472            10.0,
1473            10.0,
1474            100.0,
1475            100.0,
1476        )));
1477        doc.add_element(Element::Rectangle(ShapeElement::new(
1478            "r_top".into(),
1479            20.0,
1480            20.0,
1481            80.0,
1482            80.0,
1483        )));
1484        let vp = default_viewport();
1485
1486        // Overlapping region should return top element
1487        let hit = renderer.hit_test(&doc, &vp, 50.0, 50.0);
1488        assert_eq!(hit, Some("r_top".into()));
1489    }
1490
1491    // ── Viewport transform ──────────────────────────────────────────
1492
1493    #[test]
1494    fn test_screen_to_world_identity() {
1495        let vp = default_viewport();
1496        let (wx, wy) = screen_to_world(&vp, 100.0, 200.0);
1497        assert!((wx - 100.0).abs() < 0.01);
1498        assert!((wy - 200.0).abs() < 0.01);
1499    }
1500
1501    #[test]
1502    fn test_screen_to_world_with_zoom() {
1503        let vp = ViewState {
1504            scroll_x: 0.0,
1505            scroll_y: 0.0,
1506            zoom: 2.0,
1507        };
1508        let (wx, wy) = screen_to_world(&vp, 100.0, 200.0);
1509        assert!((wx - 50.0).abs() < 0.01);
1510        assert!((wy - 100.0).abs() < 0.01);
1511    }
1512
1513    #[test]
1514    fn test_screen_to_world_with_scroll() {
1515        let vp = ViewState {
1516            scroll_x: 50.0,
1517            scroll_y: 30.0,
1518            zoom: 1.0,
1519        };
1520        let (wx, wy) = screen_to_world(&vp, 100.0, 80.0);
1521        assert!((wx - 50.0).abs() < 0.01);
1522        assert!((wy - 50.0).abs() < 0.01);
1523    }
1524
1525    #[test]
1526    fn test_viewport_transform_identity() {
1527        let vp = default_viewport();
1528        let t = viewport_transform(&vp, 1.0);
1529        assert!((t.sx - 1.0).abs() < 0.01);
1530        assert!((t.sy - 1.0).abs() < 0.01);
1531        assert!(t.tx.abs() < 0.01);
1532        assert!(t.ty.abs() < 0.01);
1533    }
1534
1535    #[test]
1536    fn test_viewport_transform_with_zoom_and_scroll() {
1537        let vp = ViewState {
1538            scroll_x: 100.0,
1539            scroll_y: 50.0,
1540            zoom: 2.0,
1541        };
1542        let t = viewport_transform(&vp, 1.0);
1543        assert!((t.sx - 2.0).abs() < 0.01);
1544        assert!((t.tx - 100.0).abs() < 0.01);
1545        assert!((t.ty - 50.0).abs() < 0.01);
1546    }
1547
1548    // ── elements_in_rect ────────────────────────────────────────────
1549
1550    #[test]
1551    fn test_elements_in_rect() {
1552        let renderer = Renderer::new(small_config());
1553        let mut doc = make_doc();
1554        doc.add_element(Element::Rectangle(ShapeElement::new(
1555            "r1".into(),
1556            10.0,
1557            10.0,
1558            30.0,
1559            30.0,
1560        )));
1561        doc.add_element(Element::Rectangle(ShapeElement::new(
1562            "r2".into(),
1563            100.0,
1564            100.0,
1565            30.0,
1566            30.0,
1567        )));
1568        let vp = default_viewport();
1569
1570        // Selection that covers r1 but not r2
1571        let ids = renderer.elements_in_rect(&doc, &vp, Bounds::new(0.0, 0.0, 50.0, 50.0));
1572        assert!(ids.contains(&"r1".to_string()));
1573        assert!(!ids.contains(&"r2".to_string()));
1574    }
1575
1576    // ── Color parsing ───────────────────────────────────────────────
1577
1578    #[test]
1579    fn test_parse_color_hex6() {
1580        let c = parse_color("#3b82f6", 1.0);
1581        assert_eq!(c, Color::from_rgba8(0x3b, 0x82, 0xf6, 255));
1582    }
1583
1584    #[test]
1585    fn test_parse_color_hex3() {
1586        let c = parse_color("#fff", 1.0);
1587        assert_eq!(c, Color::from_rgba8(255, 255, 255, 255));
1588    }
1589
1590    #[test]
1591    fn test_parse_color_with_opacity() {
1592        let c = parse_color("#ffffff", 0.5);
1593        assert_eq!(c, Color::from_rgba8(255, 255, 255, 127));
1594    }
1595
1596    #[test]
1597    fn test_parse_color_invalid_fallback() {
1598        let c = parse_color("not-a-color", 1.0);
1599        assert_eq!(
1600            c,
1601            Color::from_rgba8(DEFAULT_STROKE_R, DEFAULT_STROKE_G, DEFAULT_STROKE_B, 255)
1602        );
1603    }
1604
1605    // ── Geometry helpers ────────────────────────────────────────────
1606
1607    #[test]
1608    fn test_point_to_segment_distance() {
1609        // Point on segment
1610        let d = point_to_segment_distance(5.0, 5.0, 0.0, 0.0, 10.0, 10.0);
1611        assert!(d < 0.01);
1612
1613        // Point off to the side
1614        let d = point_to_segment_distance(0.0, 10.0, 0.0, 0.0, 10.0, 0.0);
1615        assert!((d - 10.0).abs() < 0.01);
1616    }
1617
1618    // ── Selection rendering doesn't panic ───────────────────────────
1619
1620    #[test]
1621    fn test_render_with_selection() {
1622        let renderer = Renderer::new(small_config());
1623        let mut doc = make_doc();
1624        doc.add_element(Element::Rectangle(ShapeElement::new(
1625            "r1".into(),
1626            10.0,
1627            10.0,
1628            80.0,
1629            60.0,
1630        )));
1631        let vp = default_viewport();
1632        let _ = renderer.render(&doc, &vp, &["r1"], None);
1633    }
1634
1635    #[test]
1636    fn test_render_with_rubber_band() {
1637        let renderer = Renderer::new(small_config());
1638        let doc = make_doc();
1639        let vp = default_viewport();
1640        let sb = Bounds::new(10.0, 10.0, 100.0, 80.0);
1641        let _ = renderer.render(&doc, &vp, &[], Some(sb));
1642    }
1643
1644    // ── Negative dimensions (drag from bottom-right to top-left) ────
1645
1646    #[test]
1647    fn test_render_negative_dimensions() {
1648        let renderer = Renderer::new(small_config());
1649        let mut doc = make_doc();
1650        doc.add_element(Element::Rectangle(ShapeElement::new(
1651            "r1".into(),
1652            100.0,
1653            100.0,
1654            -50.0,
1655            -30.0,
1656        )));
1657        let _ = renderer.render(&doc, &default_viewport(), &[], None);
1658    }
1659}