drafftink_render/
vello_impl.rs

1//! Vello-based renderer implementation.
2
3use crate::renderer::{RenderContext, Renderer, ShapeRenderer};
4use crate::text_editor::TextEditState;
5use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as KurboShape, Stroke, Vec2};
6use parley::layout::PositionedLayoutItem;
7use parley::{FontContext, LayoutContext};
8use peniko::{Brush, Color, Fill};
9use drafftink_core::selection::{get_handles, Handle, HandleKind};
10use drafftink_core::shapes::{Shape, ShapeStyle, ShapeTrait};
11use drafftink_core::snap::{SnapTarget, SnapTargetKind};
12use vello::Scene;
13
14/// Result of PNG rendering - contains the raw RGBA pixel data and dimensions.
15#[derive(Debug)]
16pub struct PngRenderResult {
17    /// RGBA pixel data (4 bytes per pixel).
18    pub rgba_data: Vec<u8>,
19    /// Image width in pixels.
20    pub width: u32,
21    /// Image height in pixels.
22    pub height: u32,
23}
24
25/// Embedded GelPen fonts (Regular, Light, Heavy variants)
26static GELPEN_REGULAR: &[u8] = include_bytes!("../assets/GelPen.ttf");
27static GELPEN_LIGHT: &[u8] = include_bytes!("../assets/GelPenLight.ttf");
28static GELPEN_HEAVY: &[u8] = include_bytes!("../assets/GelPenHeavy.ttf");
29/// Embedded Roboto fonts (Light, Regular, Bold)
30static ROBOTO_LIGHT: &[u8] = include_bytes!("../assets/Roboto-Light.ttf");
31static ROBOTO_REGULAR: &[u8] = include_bytes!("../assets/Roboto-Regular.ttf");
32static ROBOTO_BOLD: &[u8] = include_bytes!("../assets/Roboto-Bold.ttf");
33/// Embedded Architects Daughter font for handwritten style
34static ARCHITECTS_DAUGHTER: &[u8] = include_bytes!("../assets/ArchitectsDaughter.ttf");
35
36/// Vello-based renderer for GPU-accelerated 2D graphics.
37pub struct VelloRenderer {
38    /// The Vello scene being built.
39    scene: Scene,
40    /// Selection highlight color.
41    selection_color: Color,
42    /// Font context for text rendering (cached to avoid re-registering fonts).
43    font_cx: FontContext,
44    /// Layout context for text rendering.
45    layout_cx: LayoutContext<Brush>,
46    /// Current zoom level (for zoom-independent UI elements).
47    zoom: f64,
48    /// Image cache to avoid re-decoding images every frame.
49    /// Key is the shape ID (as string), value is the decoded peniko ImageData.
50    image_cache: std::collections::HashMap<String, peniko::ImageData>,
51}
52
53impl Default for VelloRenderer {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59/// Convert a Parley BoundingBox to a Kurbo Rect.
60fn convert_rect(rect: &parley::BoundingBox) -> Rect {
61    Rect::new(rect.x0, rect.y0, rect.x1, rect.y1)
62}
63
64/// Simple seeded random number generator (xorshift32).
65/// Used for deterministic hand-drawn effects.
66struct SimpleRng {
67    state: u32,
68}
69
70impl SimpleRng {
71    fn new(seed: u32) -> Self {
72        Self { state: seed.max(1) }
73    }
74    
75    fn next_u32(&mut self) -> u32 {
76        let mut x = self.state;
77        x ^= x << 13;
78        x ^= x >> 17;
79        x ^= x << 5;
80        self.state = x;
81        x
82    }
83    
84    /// Random float in range [-1, 1]
85    fn next_f64(&mut self) -> f64 {
86        (self.next_u32() as f64 / u32::MAX as f64) * 2.0 - 1.0
87    }
88    
89    /// Random offset scaled by amount
90    fn offset(&mut self, amount: f64) -> f64 {
91        self.next_f64() * amount
92    }
93}
94
95/// Apply hand-drawn effect to a path based on roughness level.
96/// This mimics the Excalidraw/rough.js algorithm:
97/// - Endpoints are randomly offset (lines overshoot/undershoot at corners)
98/// - Lines have a slight bow (curve in the middle)
99/// - Each stroke_index produces completely different randomness
100/// 
101/// roughness: 0 = clean, 1 = slight wobble, 2 = very sketchy
102/// seed: stable random seed from the shape's style (persisted, doesn't change on transform)
103/// stroke_index: 0 or 1 for multi-stroke effect (different random offsets)
104fn apply_hand_drawn_effect(path: &BezPath, roughness: f64, zoom: f64, seed: u32, stroke_index: u32) -> BezPath {
105    if roughness <= 0.0 {
106        return path.clone();
107    }
108    
109    // Scale effect inversely with zoom so it looks consistent at all zoom levels
110    let scale = 1.0 / zoom.sqrt();
111    
112    // Values tuned to match Excalidraw/rough.js feel
113    // These create the "overshoot" effect at corners
114    let max_randomness_offset = roughness * 2.0 * scale;
115    let bowing = roughness * 1.0;
116    
117    // Use the shape's stable seed combined with stroke_index for deterministic randomness
118    // The seed is stored in the shape's style, so it doesn't change when the shape is transformed
119    let combined_seed = seed.wrapping_add(stroke_index.wrapping_mul(99991)); // Large prime for very different sequences
120    let mut rng = SimpleRng::new(combined_seed);
121    
122    let mut result = BezPath::new();
123    let mut last_point = Point::ZERO;
124    
125    for el in path.elements() {
126        match el {
127            PathEl::MoveTo(p) => {
128                // Offset the start point
129                let wobbled = Point::new(
130                    p.x + rng.offset(max_randomness_offset),
131                    p.y + rng.offset(max_randomness_offset),
132                );
133                result.move_to(wobbled);
134                last_point = *p;
135            }
136            PathEl::LineTo(p) => {
137                // This is the key rough.js algorithm for lines:
138                // 1. Calculate line length
139                // 2. Add bowing (perpendicular offset at midpoint)
140                // 3. Offset both endpoints randomly (creates overshoot)
141                
142                let dx = p.x - last_point.x;
143                let dy = p.y - last_point.y;
144                let len = (dx * dx + dy * dy).sqrt();
145                
146                // Calculate bowing amount - proportional to length
147                let bow_offset = bowing * roughness * len / 200.0;
148                let bow = rng.offset(bow_offset) * scale;
149                
150                // Perpendicular vector for bowing
151                let (perp_x, perp_y) = if len > 0.001 {
152                    (-dy / len, dx / len)
153                } else {
154                    (0.0, 0.0)
155                };
156                
157                // Control point with bowing
158                let mid_x = (last_point.x + p.x) / 2.0 + perp_x * bow;
159                let mid_y = (last_point.y + p.y) / 2.0 + perp_y * bow;
160                
161                // End point with random offset (creates overshoot at corners)
162                let end = Point::new(
163                    p.x + rng.offset(max_randomness_offset),
164                    p.y + rng.offset(max_randomness_offset),
165                );
166                
167                // Use quadratic bezier for the bowed line
168                result.quad_to(Point::new(mid_x, mid_y), end);
169                last_point = *p;
170            }
171            PathEl::QuadTo(p1, p2) => {
172                let wobbled_p1 = Point::new(
173                    p1.x + rng.offset(max_randomness_offset * 0.7),
174                    p1.y + rng.offset(max_randomness_offset * 0.7),
175                );
176                let wobbled_p2 = Point::new(
177                    p2.x + rng.offset(max_randomness_offset),
178                    p2.y + rng.offset(max_randomness_offset),
179                );
180                result.quad_to(wobbled_p1, wobbled_p2);
181                last_point = *p2;
182            }
183            PathEl::CurveTo(p1, p2, p3) => {
184                let wobbled_p1 = Point::new(
185                    p1.x + rng.offset(max_randomness_offset * 0.5),
186                    p1.y + rng.offset(max_randomness_offset * 0.5),
187                );
188                let wobbled_p2 = Point::new(
189                    p2.x + rng.offset(max_randomness_offset * 0.5),
190                    p2.y + rng.offset(max_randomness_offset * 0.5),
191                );
192                let wobbled_p3 = Point::new(
193                    p3.x + rng.offset(max_randomness_offset),
194                    p3.y + rng.offset(max_randomness_offset),
195                );
196                result.curve_to(wobbled_p1, wobbled_p2, wobbled_p3);
197                last_point = *p3;
198            }
199            PathEl::ClosePath => {
200                // Don't close - let the overshoot show at the closing corner too
201                // The path visually closes but endpoints won't match perfectly
202                result.close_path();
203            }
204        }
205    }
206    
207    result
208}
209
210impl VelloRenderer {
211    /// Create a new Vello renderer.
212    pub fn new() -> Self {
213        let mut font_cx = FontContext::new();
214        // Register all font variants
215        font_cx.collection.register_fonts(
216            vello::peniko::Blob::new(std::sync::Arc::new(GELPEN_REGULAR)),
217            None,
218        );
219        font_cx.collection.register_fonts(
220            vello::peniko::Blob::new(std::sync::Arc::new(GELPEN_LIGHT)),
221            None,
222        );
223        font_cx.collection.register_fonts(
224            vello::peniko::Blob::new(std::sync::Arc::new(GELPEN_HEAVY)),
225            None,
226        );
227        font_cx.collection.register_fonts(
228            vello::peniko::Blob::new(std::sync::Arc::new(ROBOTO_LIGHT)),
229            None,
230        );
231        font_cx.collection.register_fonts(
232            vello::peniko::Blob::new(std::sync::Arc::new(ROBOTO_REGULAR)),
233            None,
234        );
235        font_cx.collection.register_fonts(
236            vello::peniko::Blob::new(std::sync::Arc::new(ROBOTO_BOLD)),
237            None,
238        );
239        font_cx.collection.register_fonts(
240            vello::peniko::Blob::new(std::sync::Arc::new(ARCHITECTS_DAUGHTER)),
241            None,
242        );
243        
244        Self {
245            scene: Scene::new(),
246            selection_color: Color::from_rgba8(59, 130, 246, 255),
247            font_cx,
248            layout_cx: LayoutContext::new(),
249            zoom: 1.0,
250            image_cache: std::collections::HashMap::new(),
251        }
252    }
253
254    /// Get the built scene for rendering.
255    pub fn scene(&self) -> &Scene {
256        &self.scene
257    }
258
259    /// Take ownership of the scene (resets internal scene).
260    pub fn take_scene(&mut self) -> Scene {
261        std::mem::take(&mut self.scene)
262    }
263
264    /// Get mutable references to both font and layout contexts for text editing.
265    pub fn contexts_mut(&mut self) -> (&mut FontContext, &mut LayoutContext<Brush>) {
266        (&mut self.font_cx, &mut self.layout_cx)
267    }
268    
269    /// Build a scene for export (shapes only, no grid/selection/guides).
270    /// Returns the scene and the scaled bounds (for texture dimensions).
271    /// 
272    /// `scale` is the export resolution multiplier (1 = 1x, 2 = 2x, 3 = 3x).
273    pub fn build_export_scene(&mut self, document: &drafftink_core::canvas::CanvasDocument, scale: f64) -> (Scene, Option<Rect>) {
274        self.scene.reset();
275        self.zoom = scale;
276        
277        let bounds = document.bounds();
278        
279        // If no shapes, return empty scene
280        if bounds.is_none() {
281            return (std::mem::take(&mut self.scene), None);
282        }
283        
284        let bounds = bounds.unwrap();
285        
286        // Add padding around the content (in logical pixels)
287        let padding = 20.0;
288        let padded_bounds = bounds.inflate(padding, padding);
289        
290        // Transform: translate to origin, then scale up
291        let transform = Affine::scale(scale)
292            * Affine::translate((-padded_bounds.x0, -padded_bounds.y0));
293        
294        // Scaled output dimensions
295        let scaled_width = padded_bounds.width() * scale;
296        let scaled_height = padded_bounds.height() * scale;
297        
298        // Fill background with white (at scaled size)
299        let bg_rect = Rect::new(0.0, 0.0, scaled_width, scaled_height);
300        self.scene.fill(
301            Fill::NonZero,
302            Affine::IDENTITY,
303            Color::WHITE,
304            None,
305            &bg_rect,
306        );
307        
308        // Render all shapes with scaled transform
309        for shape in document.shapes_ordered() {
310            self.render_shape(shape, transform, false);
311        }
312        
313        // Return scaled bounds for texture dimensions
314        let scaled_bounds = Rect::new(0.0, 0.0, scaled_width, scaled_height);
315        (std::mem::take(&mut self.scene), Some(scaled_bounds))
316    }
317    
318    /// Build a scene for exporting selected shapes only.
319    /// 
320    /// `scale` is the export resolution multiplier (1 = 1x, 2 = 2x, 3 = 3x).
321    pub fn build_export_scene_selection(
322        &mut self, 
323        document: &drafftink_core::canvas::CanvasDocument,
324        selection: &[drafftink_core::shapes::ShapeId],
325        scale: f64,
326    ) -> (Scene, Option<Rect>) {
327        self.scene.reset();
328        self.zoom = scale;
329        
330        if selection.is_empty() {
331            return (std::mem::take(&mut self.scene), None);
332        }
333        
334        // Calculate bounds of selected shapes
335        let mut min_x = f64::MAX;
336        let mut min_y = f64::MAX;
337        let mut max_x = f64::MIN;
338        let mut max_y = f64::MIN;
339        
340        let mut shapes_to_render = Vec::new();
341        for &shape_id in selection {
342            if let Some(shape) = document.get_shape(shape_id) {
343                let b = shape.bounds();
344                min_x = min_x.min(b.x0);
345                min_y = min_y.min(b.y0);
346                max_x = max_x.max(b.x1);
347                max_y = max_y.max(b.y1);
348                shapes_to_render.push(shape);
349            }
350        }
351        
352        if shapes_to_render.is_empty() {
353            return (std::mem::take(&mut self.scene), None);
354        }
355        
356        let bounds = Rect::new(min_x, min_y, max_x, max_y);
357        
358        // Add padding around the content (in logical pixels)
359        let padding = 20.0;
360        let padded_bounds = bounds.inflate(padding, padding);
361        
362        // Transform: translate to origin, then scale up
363        let transform = Affine::scale(scale)
364            * Affine::translate((-padded_bounds.x0, -padded_bounds.y0));
365        
366        // Scaled output dimensions
367        let scaled_width = padded_bounds.width() * scale;
368        let scaled_height = padded_bounds.height() * scale;
369        
370        // Fill background with white (at scaled size)
371        let bg_rect = Rect::new(0.0, 0.0, scaled_width, scaled_height);
372        self.scene.fill(
373            Fill::NonZero,
374            Affine::IDENTITY,
375            Color::WHITE,
376            None,
377            &bg_rect,
378        );
379        
380        // Render selected shapes with scaled transform
381        for shape in shapes_to_render {
382            self.render_shape(shape, transform, false);
383        }
384        
385        // Return scaled bounds for texture dimensions
386        let scaled_bounds = Rect::new(0.0, 0.0, scaled_width, scaled_height);
387        (std::mem::take(&mut self.scene), Some(scaled_bounds))
388    }
389
390    /// Render a shape path with the given style.
391    fn render_path(&mut self, path: &BezPath, style: &ShapeStyle, transform: Affine) {
392        let roughness = style.sloppiness.roughness();
393        let seed = style.seed;
394        
395        // Fill if present (use clean path for fill)
396        if let Some(fill_color) = style.fill() {
397            let fill_path = if roughness > 0.0 {
398                apply_hand_drawn_effect(path, roughness * 0.3, self.zoom, seed, 0)
399            } else {
400                path.clone()
401            };
402            self.scene.fill(
403                Fill::NonZero,
404                transform,
405                fill_color,
406                None,
407                &fill_path,
408            );
409        }
410
411        // For hand-drawn style, draw multiple strokes like rough.js
412        if roughness > 0.0 {
413            let stroke = Stroke::new(style.stroke_width);
414            
415            // First stroke
416            let path1 = apply_hand_drawn_effect(path, roughness, self.zoom, seed, 0);
417            self.scene.stroke(
418                &stroke,
419                transform,
420                style.stroke(),
421                None,
422                &path1,
423            );
424            
425            // Second stroke with different seed - this creates the "sketchy" double-line effect
426            let path2 = apply_hand_drawn_effect(path, roughness, self.zoom, seed, 1);
427            self.scene.stroke(
428                &stroke,
429                transform,
430                style.stroke(),
431                None,
432                &path2,
433            );
434        } else {
435            // Clean stroke for Architect mode
436            let stroke = Stroke::new(style.stroke_width);
437            self.scene.stroke(
438                &stroke,
439                transform,
440                style.stroke(),
441                None,
442                path,
443            );
444        }
445    }
446
447    /// Render a text shape using Parley for proper text layout.
448    fn render_text(&mut self, text: &drafftink_core::shapes::Text, transform: Affine) {
449        use parley::layout::PositionedLayoutItem;
450        use parley::StyleProperty;
451        
452        // Skip empty text
453        if text.content.is_empty() {
454            // Draw a placeholder cursor/caret for empty text (position is top-left)
455            let cursor_height = text.font_size * 1.2;
456            let cursor = kurbo::Line::new(
457                Point::new(text.position.x, text.position.y),
458                Point::new(text.position.x, text.position.y + cursor_height),
459            );
460            let stroke = Stroke::new(2.0);
461            self.scene.stroke(&stroke, transform, Color::from_rgba8(100, 100, 100, 200), None, &cursor);
462            return;
463        }
464        
465        use drafftink_core::shapes::{FontFamily, FontWeight};
466        
467        let style = &text.style;
468        let brush = Brush::Solid(style.stroke());
469        let font_size = text.font_size as f32;
470        
471        // Determine font name based on family and weight
472        // Font names must match the name table in the TTF files
473        // For GelPen: each weight is a separate font family (no weight metadata)
474        // For Roboto: Use "Roboto" family with proper weight metadata
475        //   - Roboto-Light.ttf has weight 300 (LIGHT)
476        //   - Roboto-Regular.ttf has weight 400 (NORMAL)
477        //   - Roboto-Bold.ttf has weight 700 (BOLD)
478        // For ArchitectsDaughter: single font only
479        let (font_name, parley_weight) = match (&text.font_family, &text.font_weight) {
480            (FontFamily::GelPen, FontWeight::Light) => ("GelPenLight", parley::FontWeight::NORMAL),
481            (FontFamily::GelPen, FontWeight::Regular) => ("GelPen", parley::FontWeight::NORMAL),
482            (FontFamily::GelPen, FontWeight::Heavy) => ("GelPenHeavy", parley::FontWeight::NORMAL),
483            (FontFamily::Roboto, FontWeight::Light) => ("Roboto", parley::FontWeight::LIGHT),
484            (FontFamily::Roboto, FontWeight::Regular) => ("Roboto", parley::FontWeight::NORMAL),
485            (FontFamily::Roboto, FontWeight::Heavy) => ("Roboto", parley::FontWeight::BOLD),
486            (FontFamily::ArchitectsDaughter, _) => ("Architects Daughter", parley::FontWeight::NORMAL),
487        };
488        
489        // Build the layout using cached font context
490        let mut builder = self.layout_cx.ranged_builder(&mut self.font_cx, &text.content, 1.0, false);
491        builder.push_default(StyleProperty::FontSize(font_size));
492        builder.push_default(StyleProperty::Brush(brush.clone()));
493        builder.push_default(StyleProperty::FontWeight(parley_weight));
494        builder.push_default(StyleProperty::FontStack(parley::FontStack::Single(
495            parley::FontFamily::Named(font_name.into())
496        )));
497        let mut layout = builder.build(&text.content);
498        
499        // Compute layout (no max width constraint for now)
500        layout.break_all_lines(None);
501        layout.align(None, parley::Alignment::Start, parley::AlignmentOptions::default());
502        
503        // Cache the computed layout dimensions for accurate bounds
504        let layout_width = layout.width() as f64;
505        let layout_height = layout.height() as f64;
506        text.set_cached_size(layout_width, layout_height);
507        
508        // Create transform to position text
509        // text.position is where user clicked - treat as top-left of text box
510        // Parley layouts have y=0 at top, with baseline offset down
511        let text_transform = transform * Affine::translate((text.position.x, text.position.y));
512        
513        // Count glyphs for debugging
514        let mut glyph_count = 0;
515        
516        // Render each line (adapted from Parley's vello example)
517        for line in layout.lines() {
518            for item in line.items() {
519                let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
520                    continue;
521                };
522                let mut x = glyph_run.offset();
523                let y = glyph_run.baseline();
524                let run = glyph_run.run();
525                let font = run.font();
526                let font_size = run.font_size();
527                let synthesis = run.synthesis();
528                let glyph_xform = synthesis
529                    .skew()
530                    .map(|angle| Affine::skew(angle.to_radians().tan() as f64, 0.0));
531                
532                let glyphs: Vec<vello::Glyph> = glyph_run.glyphs().map(|glyph| {
533                    let gx = x + glyph.x;
534                    let gy = y - glyph.y;
535                    x += glyph.advance;
536                    glyph_count += 1;
537                    vello::Glyph {
538                        id: glyph.id,
539                        x: gx,
540                        y: gy,
541                    }
542                }).collect();
543                
544                if !glyphs.is_empty() {
545                    self.scene
546                        .draw_glyphs(font)
547                        .brush(&brush)
548                        .hint(true)
549                        .transform(text_transform)
550                        .glyph_transform(glyph_xform)
551                        .font_size(font_size)
552                        .normalized_coords(run.normalized_coords())
553                        .draw(Fill::NonZero, glyphs.into_iter());
554                }
555            }
556        }
557        
558        // If no glyphs were rendered (font not found), draw a fallback rectangle
559        if glyph_count == 0 {
560            // Approximate bounds (position is top-left)
561            let width = text.content.len() as f64 * text.font_size * 0.6;
562            let height = text.font_size * 1.2;
563            let rect = Rect::new(
564                text.position.x,
565                text.position.y,
566                text.position.x + width.max(20.0),
567                text.position.y + height,
568            );
569            self.scene.fill(Fill::NonZero, transform, Color::from_rgba8(255, 100, 100, 100), None, &rect);
570        }
571    }
572
573    /// Render an image shape.
574    fn render_image(&mut self, image: &drafftink_core::shapes::Image, transform: Affine) {
575        use std::sync::Arc;
576        
577        let id_str = image.id().to_string();
578        
579        // Check if we have a cached decoded image
580        let image_data = if let Some(cached) = self.image_cache.get(&id_str) {
581            cached.clone()
582        } else {
583            // Decode the image data
584            if let Some(raw_data) = image.data() {
585                // Try to decode using the image crate
586                if let Ok(decoded) = ::image::load_from_memory(&raw_data) {
587                    let rgba = decoded.to_rgba8();
588                    let (width, height) = rgba.dimensions();
589                    let blob = peniko::Blob::new(Arc::new(rgba.into_vec()));
590                    let img_data = peniko::ImageData {
591                        data: blob,
592                        format: peniko::ImageFormat::Rgba8,
593                        width,
594                        height,
595                        alpha_type: peniko::ImageAlphaType::Alpha,
596                    };
597                    self.image_cache.insert(id_str.clone(), img_data.clone());
598                    img_data
599                } else {
600                    // Failed to decode - draw placeholder
601                    self.render_image_placeholder(image, transform, "Failed to decode");
602                    return;
603                }
604            } else {
605                // No data - draw placeholder
606                self.render_image_placeholder(image, transform, "No image data");
607                return;
608            }
609        };
610        
611        // Calculate the transform to scale and position the image
612        let bounds = image.bounds();
613        let scale_x = bounds.width() / image_data.width as f64;
614        let scale_y = bounds.height() / image_data.height as f64;
615        
616        let image_transform = transform
617            * Affine::translate((bounds.x0, bounds.y0))
618            * Affine::scale_non_uniform(scale_x, scale_y);
619        
620        self.scene.draw_image(&image_data.into(), image_transform);
621    }
622    
623    /// Render a placeholder for images that couldn't be loaded.
624    fn render_image_placeholder(&mut self, image: &drafftink_core::shapes::Image, transform: Affine, _msg: &str) {
625        let bounds = image.bounds();
626        
627        // Draw a gray rectangle with an X
628        let rect_path = bounds.to_path(0.1);
629        self.scene.fill(Fill::NonZero, transform, Color::from_rgba8(200, 200, 200, 255), None, &rect_path);
630        
631        // Draw diagonal lines (X pattern)
632        let stroke = Stroke::new(2.0);
633        let mut x_path = BezPath::new();
634        x_path.move_to(Point::new(bounds.x0, bounds.y0));
635        x_path.line_to(Point::new(bounds.x1, bounds.y1));
636        x_path.move_to(Point::new(bounds.x1, bounds.y0));
637        x_path.line_to(Point::new(bounds.x0, bounds.y1));
638        self.scene.stroke(&stroke, transform, Color::from_rgba8(150, 150, 150, 255), None, &x_path);
639        
640        // Draw border
641        self.scene.stroke(&stroke, transform, Color::from_rgba8(100, 100, 100, 255), None, &rect_path);
642    }
643
644    /// Render a text shape in edit mode using PlainEditor state.
645    /// This renders the text with cursor and selection highlights.
646    pub fn render_text_editing(
647        &mut self,
648        text: &drafftink_core::shapes::Text,
649        edit_state: &mut TextEditState,
650        transform: Affine,
651    ) {
652        use drafftink_core::shapes::{FontFamily as ShapeFontFamily, FontWeight};
653        
654        let style = &text.style;
655        let brush = Brush::Solid(style.stroke());
656        
657        // Determine font name and parley weight based on family and weight
658        // Use same logic as render_text - all Roboto variants use "Roboto" family with weight
659        let (font_name, parley_weight) = match (&text.font_family, &text.font_weight) {
660            (ShapeFontFamily::GelPen, FontWeight::Light) => ("GelPenLight", parley::FontWeight::NORMAL),
661            (ShapeFontFamily::GelPen, FontWeight::Regular) => ("GelPen", parley::FontWeight::NORMAL),
662            (ShapeFontFamily::GelPen, FontWeight::Heavy) => ("GelPenHeavy", parley::FontWeight::NORMAL),
663            (ShapeFontFamily::Roboto, FontWeight::Light) => ("Roboto", parley::FontWeight::LIGHT),
664            (ShapeFontFamily::Roboto, FontWeight::Regular) => ("Roboto", parley::FontWeight::NORMAL),
665            (ShapeFontFamily::Roboto, FontWeight::Heavy) => ("Roboto", parley::FontWeight::BOLD),
666            (ShapeFontFamily::ArchitectsDaughter, _) => ("Architects Daughter", parley::FontWeight::NORMAL),
667        };
668        
669        // Configure the editor styles
670        edit_state.set_font_size(text.font_size as f32);
671        edit_state.set_brush(brush.clone());
672        
673        // Set the font family and weight in the editor
674        {
675            use parley::{FontStack, FontFamily, StyleProperty};
676            let styles = edit_state.editor_mut().edit_styles();
677            styles.insert(StyleProperty::FontStack(FontStack::Single(
678                FontFamily::Named(font_name.into())
679            )));
680            styles.insert(StyleProperty::FontWeight(parley_weight));
681        }
682        
683        // Create transform to position text at the shape's position
684        let text_transform = transform * Affine::translate((text.position.x, text.position.y));
685        
686        // IMPORTANT: First compute the layout - this must happen before cursor/selection geometry
687        let layout = edit_state.editor_mut().layout(&mut self.font_cx, &mut self.layout_cx);
688        
689        // Render glyphs first (text content)
690        for line in layout.lines() {
691            for item in line.items() {
692                let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
693                    continue;
694                };
695                let glyph_style = glyph_run.style();
696                let mut x = glyph_run.offset();
697                let y = glyph_run.baseline();
698                let run = glyph_run.run();
699                let font = run.font();
700                let font_size = run.font_size();
701                let synthesis = run.synthesis();
702                let glyph_xform = synthesis
703                    .skew()
704                    .map(|angle| Affine::skew(angle.to_radians().tan() as f64, 0.0));
705                
706                let glyphs: Vec<vello::Glyph> = glyph_run.glyphs().map(|glyph| {
707                    let gx = x + glyph.x;
708                    let gy = y - glyph.y;
709                    x += glyph.advance;
710                    vello::Glyph {
711                        id: glyph.id,
712                        x: gx,
713                        y: gy,
714                    }
715                }).collect();
716                
717                if !glyphs.is_empty() {
718                    self.scene
719                        .draw_glyphs(font)
720                        .brush(&glyph_style.brush)
721                        .hint(true)
722                        .transform(text_transform)
723                        .glyph_transform(glyph_xform)
724                        .font_size(font_size)
725                        .normalized_coords(run.normalized_coords())
726                        .draw(Fill::NonZero, glyphs.into_iter());
727                }
728            }
729        }
730        
731        // Selection color (semi-transparent blue)
732        let selection_color = Color::from_rgba8(70, 130, 180, 128); // STEEL_BLUE-ish
733        
734        // Draw selection background (now layout is computed)
735        edit_state.editor().selection_geometry_with(|rect, _| {
736            self.scene.fill(
737                Fill::NonZero,
738                text_transform,
739                selection_color,
740                None,
741                &convert_rect(&rect),
742            );
743        });
744        
745        // Draw cursor if visible (now layout is computed)
746        if edit_state.is_cursor_visible() {
747            if let Some(cursor) = edit_state.editor().cursor_geometry(1.5) {
748                // Cursor color (contrasting with text)
749                let cursor_color = Color::from_rgba8(0, 0, 0, 255);
750                self.scene.fill(
751                    Fill::NonZero,
752                    text_transform,
753                    cursor_color,
754                    None,
755                    &convert_rect(&cursor),
756                );
757            } else if edit_state.text().is_empty() {
758                // If text is empty, show a placeholder cursor at origin
759                let cursor_height = text.font_size * 1.2;
760                let cursor_rect = Rect::new(0.0, 0.0, 1.5, cursor_height);
761                self.scene.fill(
762                    Fill::NonZero,
763                    text_transform,
764                    Color::from_rgba8(0, 0, 0, 255),
765                    None,
766                    &cursor_rect,
767                );
768            }
769        }
770    }
771
772    /// Render shape-specific selection handles.
773    /// Handles are scaled inversely with zoom to maintain constant screen size.
774    fn render_shape_handles(&mut self, shape: &Shape, transform: Affine) {
775        let handles = get_handles(shape);
776        // Scale handle size inversely with zoom to maintain constant screen size
777        let handle_size = 8.0 / self.zoom;
778        let stroke_width = 1.0 / self.zoom;
779        let dash_len = 4.0 / self.zoom;
780
781        // For lines/arrows, draw a light dashed line connecting the endpoints
782        // For rectangles/ellipses, draw the bounding box
783        match shape {
784            Shape::Line(_) | Shape::Arrow(_) => {
785                // Just draw the endpoint handles, no bounding box
786            }
787            _ => {
788                // Draw selection rectangle for non-line shapes
789                let bounds = shape.bounds();
790                let stroke = Stroke::new(stroke_width).with_dashes(0.0, &[dash_len, dash_len]);
791                let mut path = BezPath::new();
792                path.move_to(Point::new(bounds.x0, bounds.y0));
793                path.line_to(Point::new(bounds.x1, bounds.y0));
794                path.line_to(Point::new(bounds.x1, bounds.y1));
795                path.line_to(Point::new(bounds.x0, bounds.y1));
796                path.close_path();
797                
798                self.scene.stroke(
799                    &stroke,
800                    transform,
801                    self.selection_color,
802                    None,
803                    &path,
804                );
805            }
806        }
807
808        // Draw handles
809        for handle in handles {
810            self.render_handle(&handle, transform, handle_size);
811        }
812    }
813
814    /// Render a single handle.
815    /// Stroke widths are scaled inversely with zoom to maintain constant screen size.
816    fn render_handle(&mut self, handle: &Handle, transform: Affine, size: f64) {
817        let pos = handle.position;
818        let stroke_width_thick = 2.0 / self.zoom;
819        let stroke_width_thin = 1.5 / self.zoom;
820        
821        // Different handle shapes based on type
822        match handle.kind {
823            HandleKind::Endpoint(_) => {
824                // Circle handle for endpoints (lines/arrows)
825                let radius = size / 2.0;
826                let ellipse = kurbo::Ellipse::new(pos, (radius, radius), 0.0);
827                let path = ellipse.to_path(0.1);
828                
829                // White fill
830                self.scene.fill(
831                    Fill::NonZero,
832                    transform,
833                    Color::WHITE,
834                    None,
835                    &path,
836                );
837                
838                // Blue border
839                self.scene.stroke(
840                    &Stroke::new(stroke_width_thick),
841                    transform,
842                    self.selection_color,
843                    None,
844                    &path,
845                );
846            }
847            HandleKind::Corner(_) | HandleKind::Edge(_) => {
848                // Square handle for corners/edges
849                let half = size / 2.0;
850                let rect = Rect::new(
851                    pos.x - half,
852                    pos.y - half,
853                    pos.x + half,
854                    pos.y + half,
855                );
856                let path = rect.to_path(0.1);
857                
858                // White fill
859                self.scene.fill(
860                    Fill::NonZero,
861                    transform,
862                    Color::WHITE,
863                    None,
864                    &path,
865                );
866                
867                // Blue border
868                self.scene.stroke(
869                    &Stroke::new(stroke_width_thin),
870                    transform,
871                    self.selection_color,
872                    None,
873                    &path,
874                );
875            }
876        }
877    }
878}
879
880impl Renderer for VelloRenderer {
881    fn build_scene(&mut self, ctx: &RenderContext) {
882        // Clear the scene
883        self.scene.reset();
884        self.selection_color = ctx.selection_color;
885        self.zoom = ctx.canvas.camera.zoom;
886
887        let camera_transform = ctx.canvas.camera.transform();
888
889        // Draw grid based on style
890        use crate::renderer::GridStyle;
891        match ctx.grid_style {
892            GridStyle::None => {}
893            GridStyle::Lines => {
894                self.render_grid_lines(
895                    Rect::new(0.0, 0.0, ctx.viewport_size.width, ctx.viewport_size.height),
896                    camera_transform,
897                    20.0,
898                );
899            }
900            GridStyle::CrossPlus => {
901                self.render_grid_crosses(
902                    Rect::new(0.0, 0.0, ctx.viewport_size.width, ctx.viewport_size.height),
903                    camera_transform,
904                    20.0,
905                );
906            }
907            GridStyle::Dots => {
908                self.render_grid_dots(
909                    Rect::new(0.0, 0.0, ctx.viewport_size.width, ctx.viewport_size.height),
910                    camera_transform,
911                    20.0,
912                );
913            }
914        }
915
916        // Draw all shapes in z-order (skip shape being edited - it's rendered separately)
917        for shape in ctx.canvas.document.shapes_ordered() {
918            // Skip shape being edited (will be rendered with cursor/selection separately)
919            if ctx.editing_shape_id == Some(shape.id()) {
920                continue;
921            }
922            let is_selected = ctx.canvas.is_selected(shape.id());
923            self.render_shape(shape, camera_transform, is_selected);
924        }
925
926        // Draw preview shape if tool is active
927        if let Some(preview) = ctx.canvas.tool_manager.preview_shape() {
928            self.render_shape(&preview, camera_transform, false);
929        }
930
931        // Draw selection rectangle (marquee)
932        if let Some(rect) = ctx.selection_rect {
933            self.render_selection_rect(rect, camera_transform);
934        }
935
936        // Draw nearby snap targets (small indicators on shapes)
937        if !ctx.nearby_snap_targets.is_empty() {
938            self.render_snap_targets(&ctx.nearby_snap_targets, camera_transform);
939        }
940
941        // Draw snap guides
942        if let Some(snap_point) = ctx.snap_point {
943            self.render_snap_guides(snap_point, camera_transform, ctx.viewport_size);
944        }
945
946        // Draw angle snap guides (polar rays and arc)
947        if let Some(ref angle_info) = ctx.angle_snap_info {
948            self.render_angle_snap_guides(angle_info, camera_transform, ctx.viewport_size);
949        }
950    }
951}
952
953impl VelloRenderer {
954    /// Render snap guides (crosshairs at the snap point).
955    fn render_snap_guides(&mut self, snap_point: Point, transform: Affine, viewport_size: kurbo::Size) {
956        // Snap guide color - subtle magenta/pink
957        let guide_color = Color::from_rgba8(236, 72, 153, 180); // Pink-500 with alpha
958        
959        // Stroke width scaled inversely with zoom for constant screen appearance
960        let stroke_width = 1.0 / self.zoom;
961        let stroke = Stroke::new(stroke_width);
962        
963        // We need to draw lines in world coordinates that span the visible area
964        // Get the inverse transform to convert screen bounds to world bounds
965        let inv_transform = transform.inverse();
966        let world_top_left = inv_transform * Point::new(0.0, 0.0);
967        let world_bottom_right = inv_transform * Point::new(viewport_size.width, viewport_size.height);
968        
969        // Horizontal line through snap point (full width)
970        let mut h_path = BezPath::new();
971        h_path.move_to(Point::new(world_top_left.x, snap_point.y));
972        h_path.line_to(Point::new(world_bottom_right.x, snap_point.y));
973        self.scene.stroke(&stroke, transform, guide_color, None, &h_path);
974        
975        // Vertical line through snap point (full height)
976        let mut v_path = BezPath::new();
977        v_path.move_to(Point::new(snap_point.x, world_top_left.y));
978        v_path.line_to(Point::new(snap_point.x, world_bottom_right.y));
979        self.scene.stroke(&stroke, transform, guide_color, None, &v_path);
980        
981        // Small circle at the intersection
982        let circle_radius = 4.0 / self.zoom;
983        let circle = kurbo::Circle::new(snap_point, circle_radius);
984        self.scene.stroke(&Stroke::new(stroke_width * 2.0), transform, guide_color, None, &circle);
985    }
986
987    /// Render nearby snap targets as small indicators.
988    fn render_snap_targets(&mut self, targets: &[SnapTarget], transform: Affine) {
989        // Different colors for different target types
990        let corner_color = Color::from_rgba8(59, 130, 246, 150);    // Blue for corners
991        let midpoint_color = Color::from_rgba8(16, 185, 129, 150);  // Emerald for midpoints  
992        let center_color = Color::from_rgba8(245, 158, 11, 150);    // Amber for centers
993        
994        // Size scaled inversely with zoom for constant screen appearance
995        let size = 4.0 / self.zoom;
996        let stroke_width = 1.0 / self.zoom;
997        
998        for target in targets {
999            let color = match target.kind {
1000                SnapTargetKind::Corner => corner_color,
1001                SnapTargetKind::Midpoint => midpoint_color,
1002                SnapTargetKind::Center => center_color,
1003                SnapTargetKind::Edge => corner_color, // Treat edges like corners
1004            };
1005            
1006            match target.kind {
1007                SnapTargetKind::Corner | SnapTargetKind::Edge => {
1008                    // Draw a small square for corners
1009                    let half = size;
1010                    let rect = Rect::new(
1011                        target.point.x - half,
1012                        target.point.y - half,
1013                        target.point.x + half,
1014                        target.point.y + half,
1015                    );
1016                    self.scene.stroke(&Stroke::new(stroke_width), transform, color, None, &rect);
1017                }
1018                SnapTargetKind::Midpoint => {
1019                    // Draw a small diamond for midpoints
1020                    let mut path = BezPath::new();
1021                    path.move_to(Point::new(target.point.x, target.point.y - size));
1022                    path.line_to(Point::new(target.point.x + size, target.point.y));
1023                    path.line_to(Point::new(target.point.x, target.point.y + size));
1024                    path.line_to(Point::new(target.point.x - size, target.point.y));
1025                    path.close_path();
1026                    self.scene.stroke(&Stroke::new(stroke_width), transform, color, None, &path);
1027                }
1028                SnapTargetKind::Center => {
1029                    // Draw a small circle for centers
1030                    let circle = kurbo::Circle::new(target.point, size);
1031                    self.scene.stroke(&Stroke::new(stroke_width), transform, color, None, &circle);
1032                }
1033            }
1034        }
1035    }
1036
1037    /// Render angle snap visualization (polar rays and angle arc).
1038    fn render_angle_snap_guides(
1039        &mut self,
1040        info: &crate::renderer::AngleSnapInfo,
1041        transform: Affine,
1042        viewport_size: kurbo::Size,
1043    ) {
1044        use std::f64::consts::PI;
1045        
1046        // Colors
1047        let ray_color = Color::from_rgba8(100, 100, 100, 60); // Very subtle gray
1048        let active_ray_color = Color::from_rgba8(236, 72, 153, 200); // Magenta for active snap angle
1049        let arc_color = Color::from_rgba8(236, 72, 153, 220); // Magenta for angle arc
1050        
1051        // Stroke widths scaled inversely with zoom
1052        let thin_stroke_width = 0.5 / self.zoom;
1053        let thick_stroke_width = 1.5 / self.zoom;
1054        
1055        let start = info.start_point;
1056        
1057        // Calculate ray length based on viewport
1058        let inv_transform = transform.inverse();
1059        let world_top_left = inv_transform * Point::new(0.0, 0.0);
1060        let world_bottom_right = inv_transform * Point::new(viewport_size.width, viewport_size.height);
1061        let viewport_diagonal = ((world_bottom_right.x - world_top_left.x).powi(2)
1062            + (world_bottom_right.y - world_top_left.y).powi(2))
1063        .sqrt();
1064        let ray_length = viewport_diagonal;
1065        
1066        // Draw polar rays at 15° intervals (0°, 15°, 30°, ..., 345°)
1067        let mut path = BezPath::new();
1068        for i in 0..24 {
1069            let angle_deg = i as f64 * 15.0;
1070            let angle_rad = angle_deg * PI / 180.0;
1071            let end_x = start.x + ray_length * angle_rad.cos();
1072            let end_y = start.y + ray_length * angle_rad.sin();
1073            path.move_to(start);
1074            path.line_to(Point::new(end_x, end_y));
1075        }
1076        self.scene.stroke(&Stroke::new(thin_stroke_width), transform, ray_color, None, &path);
1077        
1078        // Highlight the active snap angle ray if snapped
1079        if info.is_snapped {
1080            let angle_rad = info.angle_degrees * PI / 180.0;
1081            let mut active_path = BezPath::new();
1082            active_path.move_to(start);
1083            active_path.line_to(Point::new(
1084                start.x + ray_length * angle_rad.cos(),
1085                start.y + ray_length * angle_rad.sin(),
1086            ));
1087            self.scene.stroke(&Stroke::new(thick_stroke_width), transform, active_ray_color, None, &active_path);
1088            
1089            // Draw angle arc from 0° to the snapped angle
1090            let arc_radius = 30.0 / self.zoom;
1091            let segments = (info.angle_degrees.abs() / 5.0).ceil() as usize;
1092            let segments = segments.max(2).min(72); // At least 2, at most 72 segments
1093            
1094            if segments > 1 {
1095                let mut arc_path = BezPath::new();
1096                let start_angle = 0.0_f64;
1097                let end_angle = info.angle_degrees * PI / 180.0;
1098                
1099                let first_x = start.x + arc_radius * start_angle.cos();
1100                let first_y = start.y + arc_radius * start_angle.sin();
1101                arc_path.move_to(Point::new(first_x, first_y));
1102                
1103                for i in 1..=segments {
1104                    let t = i as f64 / segments as f64;
1105                    let angle = start_angle + t * (end_angle - start_angle);
1106                    let x = start.x + arc_radius * angle.cos();
1107                    let y = start.y + arc_radius * angle.sin();
1108                    arc_path.line_to(Point::new(x, y));
1109                }
1110                
1111                self.scene.stroke(&Stroke::new(thick_stroke_width), transform, arc_color, None, &arc_path);
1112            }
1113            
1114            // Draw angle label (e.g., "45°")
1115            // Position the label at the midpoint of the arc
1116            let label_angle = info.angle_degrees * PI / 360.0; // Half the angle
1117            let label_radius = arc_radius + 15.0 / self.zoom;
1118            let _label_pos = Point::new(
1119                start.x + label_radius * label_angle.cos(),
1120                start.y + label_radius * label_angle.sin(),
1121            );
1122            
1123            // Note: Text rendering would require Parley/font setup which is complex
1124            // For now, we skip the text label - the arc itself shows the angle visually
1125        }
1126    }
1127
1128    /// Render a selection rectangle (marquee).
1129    /// Stroke width and dash pattern are scaled inversely with zoom.
1130    fn render_selection_rect(&mut self, rect: Rect, transform: Affine) {
1131        // Fill with semi-transparent blue
1132        let fill_color = Color::from_rgba8(59, 130, 246, 25);
1133        let mut path = BezPath::new();
1134        path.move_to(Point::new(rect.x0, rect.y0));
1135        path.line_to(Point::new(rect.x1, rect.y0));
1136        path.line_to(Point::new(rect.x1, rect.y1));
1137        path.line_to(Point::new(rect.x0, rect.y1));
1138        path.close_path();
1139
1140        self.scene.fill(
1141            Fill::NonZero,
1142            transform,
1143            fill_color,
1144            None,
1145            &path,
1146        );
1147
1148        // Stroke with blue dashed line - scale inversely with zoom
1149        let stroke_width = 1.0 / self.zoom;
1150        let dash_len = 4.0 / self.zoom;
1151        let stroke = Stroke::new(stroke_width).with_dashes(0.0, &[dash_len, dash_len]);
1152        self.scene.stroke(
1153            &stroke,
1154            transform,
1155            self.selection_color,
1156            None,
1157            &path,
1158        );
1159    }
1160}
1161
1162impl VelloRenderer {
1163    /// Calculate grid bounds from viewport and transform.
1164    fn grid_bounds(&self, viewport: Rect, transform: Affine, grid_size: f64) -> (f64, f64, f64, f64) {
1165        let inv = transform.inverse();
1166        let world_tl = inv * Point::new(viewport.x0, viewport.y0);
1167        let world_br = inv * Point::new(viewport.x1, viewport.y1);
1168
1169        let start_x = (world_tl.x / grid_size).floor() * grid_size;
1170        let start_y = (world_tl.y / grid_size).floor() * grid_size;
1171        let end_x = (world_br.x / grid_size).ceil() * grid_size;
1172        let end_y = (world_br.y / grid_size).ceil() * grid_size;
1173
1174        (start_x, start_y, end_x, end_y)
1175    }
1176
1177    /// Render full grid lines.
1178    fn render_grid_lines(&mut self, viewport: Rect, transform: Affine, grid_size: f64) {
1179        let grid_color = Color::from_rgba8(200, 200, 200, 100);
1180        let stroke = Stroke::new(0.5);
1181
1182        let (start_x, start_y, end_x, end_y) = self.grid_bounds(viewport, transform, grid_size);
1183
1184        // Vertical lines
1185        let mut x = start_x;
1186        while x <= end_x {
1187            let mut path = BezPath::new();
1188            path.move_to(Point::new(x, start_y));
1189            path.line_to(Point::new(x, end_y));
1190            self.scene.stroke(&stroke, transform, grid_color, None, &path);
1191            x += grid_size;
1192        }
1193
1194        // Horizontal lines
1195        let mut y = start_y;
1196        while y <= end_y {
1197            let mut path = BezPath::new();
1198            path.move_to(Point::new(start_x, y));
1199            path.line_to(Point::new(end_x, y));
1200            self.scene.stroke(&stroke, transform, grid_color, None, &path);
1201            y += grid_size;
1202        }
1203    }
1204
1205    /// Render grid as small crosses (+) at intersections.
1206    /// Uses batched paths for performance.
1207    fn render_grid_crosses(&mut self, viewport: Rect, transform: Affine, grid_size: f64) {
1208        let grid_color = Color::from_rgba8(180, 180, 180, 60); // Reduced opacity
1209        let stroke = Stroke::new(1.0);
1210        let cross_size = 3.0; // Half-size of the cross arms
1211
1212        let (start_x, start_y, end_x, end_y) = self.grid_bounds(viewport, transform, grid_size);
1213
1214        // Batch all crosses into a single path for performance
1215        let mut path = BezPath::new();
1216        
1217        let mut x = start_x;
1218        while x <= end_x {
1219            let mut y = start_y;
1220            while y <= end_y {
1221                // Horizontal arm
1222                path.move_to(Point::new(x - cross_size, y));
1223                path.line_to(Point::new(x + cross_size, y));
1224                // Vertical arm
1225                path.move_to(Point::new(x, y - cross_size));
1226                path.line_to(Point::new(x, y + cross_size));
1227                y += grid_size;
1228            }
1229            x += grid_size;
1230        }
1231        
1232        // Single draw call for all crosses
1233        self.scene.stroke(&stroke, transform, grid_color, None, &path);
1234    }
1235
1236    /// Render grid as dots at intersections.
1237    /// Uses batched rectangles for performance.
1238    fn render_grid_dots(&mut self, viewport: Rect, transform: Affine, grid_size: f64) {
1239        let grid_color = Color::from_rgba8(160, 160, 160, 70); // Reduced opacity
1240        let dot_size = 1.5; // Half-size of the dot
1241
1242        let (start_x, start_y, end_x, end_y) = self.grid_bounds(viewport, transform, grid_size);
1243
1244        // Batch all dots into a single path for performance
1245        let mut path = BezPath::new();
1246        
1247        let mut x = start_x;
1248        while x <= end_x {
1249            let mut y = start_y;
1250            while y <= end_y {
1251                // Add a small square for each dot (cheaper than ellipse)
1252                let rect = Rect::new(
1253                    x - dot_size,
1254                    y - dot_size,
1255                    x + dot_size,
1256                    y + dot_size,
1257                );
1258                path.move_to(Point::new(rect.x0, rect.y0));
1259                path.line_to(Point::new(rect.x1, rect.y0));
1260                path.line_to(Point::new(rect.x1, rect.y1));
1261                path.line_to(Point::new(rect.x0, rect.y1));
1262                path.close_path();
1263                y += grid_size;
1264            }
1265            x += grid_size;
1266        }
1267        
1268        // Single draw call for all dots
1269        self.scene.fill(Fill::NonZero, transform, grid_color, None, &path);
1270    }
1271
1272    #[allow(dead_code)]
1273    fn render_selection_handles(&mut self, bounds: Rect, transform: Affine) {
1274        let handle_size = 8.0;
1275        let stroke = Stroke::new(2.0);
1276
1277        // Selection rectangle
1278        let mut path = BezPath::new();
1279        path.move_to(Point::new(bounds.x0, bounds.y0));
1280        path.line_to(Point::new(bounds.x1, bounds.y0));
1281        path.line_to(Point::new(bounds.x1, bounds.y1));
1282        path.line_to(Point::new(bounds.x0, bounds.y1));
1283        path.close_path();
1284
1285        self.scene.stroke(
1286            &stroke,
1287            transform,
1288            self.selection_color,
1289            None,
1290            &path,
1291        );
1292
1293        // Corner handles
1294        let corners = [
1295            Point::new(bounds.x0, bounds.y0),
1296            Point::new(bounds.x1, bounds.y0),
1297            Point::new(bounds.x1, bounds.y1),
1298            Point::new(bounds.x0, bounds.y1),
1299        ];
1300
1301        for corner in corners {
1302            let handle_rect = Rect::new(
1303                corner.x - handle_size / 2.0,
1304                corner.y - handle_size / 2.0,
1305                corner.x + handle_size / 2.0,
1306                corner.y + handle_size / 2.0,
1307            );
1308
1309            // White fill
1310            self.scene.fill(
1311                Fill::NonZero,
1312                transform,
1313                Color::WHITE,
1314                None,
1315                &handle_rect.to_path(0.1),
1316            );
1317
1318            // Blue border
1319            self.scene.stroke(
1320                &Stroke::new(1.5),
1321                transform,
1322                self.selection_color,
1323                None,
1324                &handle_rect.to_path(0.1),
1325            );
1326        }
1327    }
1328}
1329
1330impl ShapeRenderer for VelloRenderer {
1331    fn render_shape(&mut self, shape: &Shape, transform: Affine, selected: bool) {
1332        // Special handling for different shape types
1333        match shape {
1334            Shape::Text(text) => {
1335                self.render_text(text, transform);
1336            }
1337            Shape::Group(group) => {
1338                // Render each child in the group
1339                for child in group.children() {
1340                    // Children are not individually selected when the group is selected
1341                    self.render_shape(child, transform, false);
1342                }
1343            }
1344            Shape::Image(image) => {
1345                self.render_image(image, transform);
1346            }
1347            _ => {
1348                let path = shape.to_path();
1349                self.render_path(&path, shape.style(), transform);
1350            }
1351        }
1352
1353        // Draw selection highlight with shape-specific handles
1354        if selected {
1355            self.render_shape_handles(shape, transform);
1356        }
1357    }
1358
1359    fn render_grid(&mut self, viewport: Rect, transform: Affine, grid_size: f64) {
1360        // Default implementation - full lines
1361        self.render_grid_lines(viewport, transform, grid_size);
1362    }
1363
1364    fn render_selection_handles(&mut self, _bounds: Rect, _transform: Affine) {
1365        // Not used - we use shape-specific handles via render_shape_handles
1366    }
1367}
1368
1369impl VelloRenderer {
1370    /// Draw a remote user's cursor at a screen position.
1371    /// 
1372    /// The cursor is rendered as a small pointer arrow with the user's color
1373    /// and their name shown nearby.
1374    pub fn draw_cursor(&mut self, screen_pos: Point, color: Color, label: &str) {
1375        // Draw cursor pointer (simple triangle pointing up-right)
1376        let mut path = BezPath::new();
1377        // Triangle: tip at screen_pos, pointing up-right
1378        path.move_to(screen_pos); // tip
1379        path.line_to(Point::new(screen_pos.x, screen_pos.y + 18.0)); // bottom-left
1380        path.line_to(Point::new(screen_pos.x + 14.0, screen_pos.y + 14.0)); // bottom-right
1381        path.close_path();
1382        
1383        // Fill the cursor
1384        self.scene.fill(
1385            vello::peniko::Fill::NonZero,
1386            Affine::IDENTITY,
1387            color,
1388            None,
1389            &path,
1390        );
1391        
1392        // White stroke for visibility against any background
1393        let stroke = Stroke::new(1.5);
1394        self.scene.stroke(&stroke, Affine::IDENTITY, Color::WHITE, None, &path);
1395    }
1396}
1397
1398#[cfg(test)]
1399mod tests {
1400    use super::*;
1401    use drafftink_core::canvas::Canvas;
1402    use drafftink_core::shapes::Rectangle;
1403
1404    #[test]
1405    fn test_renderer_creation() {
1406        let renderer = VelloRenderer::new();
1407        assert!(renderer.scene().encoding().is_empty());
1408    }
1409
1410    #[test]
1411    fn test_build_empty_scene() {
1412        let mut renderer = VelloRenderer::new();
1413        let canvas = Canvas::new();
1414        let ctx = RenderContext::new(&canvas, kurbo::Size::new(800.0, 600.0));
1415
1416        renderer.build_scene(&ctx);
1417        // Scene should have grid lines at minimum
1418    }
1419
1420    #[test]
1421    fn test_build_scene_with_shapes() {
1422        let mut renderer = VelloRenderer::new();
1423        let mut canvas = Canvas::new();
1424
1425        let rect = Rectangle::new(Point::new(100.0, 100.0), 200.0, 150.0);
1426        canvas.document.add_shape(Shape::Rectangle(rect));
1427
1428        let ctx = RenderContext::new(&canvas, kurbo::Size::new(800.0, 600.0));
1429        renderer.build_scene(&ctx);
1430    }
1431}