1use 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
19const BG_R: u8 = 10;
23const BG_G: u8 = 15;
24const BG_B: u8 = 26;
25
26const GRID_R: u8 = 59;
28const GRID_G: u8 = 130;
29const GRID_B: u8 = 246;
30const GRID_ALPHA: f32 = 0.08;
31
32const DEFAULT_STROKE_R: u8 = 226;
34const DEFAULT_STROKE_G: u8 = 232;
35const DEFAULT_STROKE_B: u8 = 240;
36
37const ACCENT_R: u8 = 59;
39const ACCENT_G: u8 = 130;
40const ACCENT_B: u8 = 246;
41
42const SELECTION_FILL_ALPHA: f32 = 0.08;
44
45const HANDLE_FILL_R: u8 = 255;
47const HANDLE_FILL_G: u8 = 255;
48const HANDLE_FILL_B: u8 = 255;
49
50const CORNER_RADIUS: f32 = 12.0;
52
53pub const ARROWHEAD_LENGTH: f32 = 14.0;
55pub const ARROWHEAD_ANGLE: f32 = 0.45;
56
57const HACHURE_LINE_WIDTH: f32 = 1.5;
59const HACHURE_ALPHA: f32 = 0.5;
60
61const SELECTION_PAD: f32 = 5.0;
63const SELECTION_DASH_LEN: f32 = 5.0;
64const HANDLE_RADIUS: f32 = 4.0;
65
66const GRID_SIZE: f32 = 20.0;
68const GRID_MIN_SCREEN_PX: f32 = 8.0;
70
71const HIT_TEST_PAD: f32 = 4.0;
73
74const LINE_HIT_TOLERANCE: f32 = 6.0;
76
77const TEXT_CHAR_WIDTH_FACTOR: f32 = 0.6;
79const TEXT_LINE_HEIGHT_FACTOR: f32 = 1.2;
81const TEXT_MIN_CHARS: f32 = 2.0;
83
84#[derive(Clone)]
88pub struct RenderConfig {
89 pub width: u32,
90 pub height: u32,
91 pub background: Color,
92 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum HandlePosition {
112 NorthWest,
113 NorthEast,
114 SouthWest,
115 SouthEast,
116}
117
118pub struct Renderer {
120 config: RenderConfig,
121}
122
123impl Renderer {
124 pub fn new(config: RenderConfig) -> Self {
125 Self { config }
126 }
127
128 pub fn config(&self) -> &RenderConfig {
130 &self.config
131 }
132
133 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 pixmap.fill(self.config.background);
149
150 if self.config.show_grid {
152 self.draw_grid(&mut pixmap, viewport);
153 }
154
155 let vt = viewport_transform(viewport, self.config.pixel_ratio);
157
158 for el in &doc.elements {
160 self.draw_element(&mut pixmap, el, &vt);
161 }
162
163 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 if let Some(sb) = selection_box {
172 self.draw_rubber_band(&mut pixmap, &sb);
173 }
174
175 pixmap
176 }
177
178 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 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 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 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 self.fill_shape(pixmap, &path, &el.fill, x, y, w, h, transform, opacity);
294 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 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 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 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 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 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 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 #[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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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
838fn 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 pb.move_to(x + r, y);
850 pb.line_to(x + w - r, y);
851 pb.quad_to(x + w, y, x + w, y + r);
853 pb.line_to(x + w, y + h - r);
855 pb.quad_to(x + w, y + h, x + w - r, y + h);
857 pb.line_to(x + r, y + h);
859 pb.quad_to(x, y + h, x, y + h - r);
861 pb.line_to(x, y + r);
863 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 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 pb.move_to(lerp_f32(left.0, top.0, t_lt), lerp_f32(left.1, top.1, t_lt));
914 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 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 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 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 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
978fn 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
1009fn 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
1034fn 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
1046fn 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
1053fn 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 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
1130fn 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
1145fn 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
1155fn 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
1165fn 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
1175fn 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
1186fn 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#[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 #[test]
1222 fn test_render_empty_document_produces_background() {
1223 let mut config = small_config();
1224 config.show_grid = false; 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 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 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 #[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 #[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 #[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 let hit = renderer.hit_test(&doc, &vp, 80.0, 80.0);
1420 assert_eq!(hit, Some("r1".into()));
1421
1422 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 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 let hit = renderer.hit_test(&doc, &vp, 55.0, 55.0);
1459 assert_eq!(hit, Some("l1".into()));
1460
1461 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 let hit = renderer.hit_test(&doc, &vp, 50.0, 50.0);
1488 assert_eq!(hit, Some("r_top".into()));
1489 }
1490
1491 #[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 #[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 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 #[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 #[test]
1608 fn test_point_to_segment_distance() {
1609 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 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 #[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 #[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}