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