1use crate::{
18 grid::{MOVE_HOVER_DISTANCE, PIN_PITCH, RESIZE_SHIM, ROUTE_HIT_MARGIN, TITLE_TEXT_SIZE},
19 shape::{
20 BaseShape, PinLocation, ShapeId, ShapeRef,
21 pin::{Pin, PinSide, slot},
22 },
23 theme::Style,
24 widget::{auto_route::hit_text_anchor, drawing::Drawing},
25};
26use blockworx_doc::id::{AreaId, BlockId, ImageId, PinId, RouteId, RouteLabelId};
27use blockworx_geom::{Pos2, Rect, vec2};
28use blockworx_paint::Renderer;
29
30#[derive(Clone, Copy, PartialEq, Eq, Debug)]
33pub enum PinPart {
34 Name,
35 Type,
36 Tag,
37 Stub,
38}
39
40#[derive(Clone, Copy, Debug)]
42pub enum HitTarget {
43 Title(ShapeId),
45 BlockType(BlockId),
47 Port(PinId),
49 Pin {
52 anchor: PinId,
53 location: PinLocation,
54 part: PinPart,
55 },
56 RouteLabel {
57 route: RouteId,
58 label: RouteLabelId,
59 },
60 Route(RouteId),
61 Shape(ShapeId),
63}
64
65impl Drawing<'_> {
66 pub fn resolve_at_pos(
72 &self,
73 pos: Pos2,
74 painter: &Style<'_, impl Renderer>,
75 ) -> Option<HitTarget> {
76 if let Some(shape) = self.title_at_pos(pos, painter) {
77 return Some(HitTarget::Title(shape));
78 }
79 if let Some(rect) = self.type_at_pos(pos, painter) {
80 return Some(HitTarget::BlockType(rect));
81 }
82 let as_part = |part| {
83 move |(anchor, location)| HitTarget::Pin {
84 anchor,
85 location,
86 part,
87 }
88 };
89 if let Some(pin) = self
90 .pin_text_at_pos(pos, painter)
91 .map(as_part(PinPart::Name))
92 .or_else(|| {
93 self.pin_type_at_pos(pos, painter)
94 .map(as_part(PinPart::Type))
95 })
96 .or_else(|| self.pin_tag_at_pos(pos, painter).map(as_part(PinPart::Tag)))
97 .or_else(|| self.pin_stub_at_pos(pos).map(as_part(PinPart::Stub)))
98 {
99 return Some(pin);
100 }
101 if let Some(port) = self.port_at_pos(pos) {
102 return Some(HitTarget::Port(port));
103 }
104 if let Some((route, label)) = self.route_label_at_pos(pos, painter) {
105 return Some(HitTarget::RouteLabel { route, label });
106 }
107 if let Some(route) = self.route_at_pos(pos) {
108 return Some(HitTarget::Route(route));
109 }
110 self.shape_at_pos(pos).map(HitTarget::Shape)
111 }
112}
113
114const LABEL_HIT_PAD: f32 = 4.0;
117
118impl Drawing<'_> {
119 pub fn port_at_pos(&self, pos: Pos2) -> Option<PinId> {
120 self.ports_layer()
121 .filter(|(_, shape)| shape.gui_rect().contains(pos))
122 .filter_map(|(id, _)| match id {
123 ShapeId::Port(pid) => Some(pid),
124 _ => None,
125 })
126 .last()
127 }
128
129 pub fn pins_in_rect(&self, marquee: Rect) -> Vec<PinId> {
133 let mut pins = Vec::new();
134 for (id, shape) in self.shape_candidates(marquee) {
135 if !id.is_block() {
136 continue;
137 }
138 shape.with_pins(|pid, _| {
139 if let Some(stub) = shape.pin_stub_rect(pid)
140 && marquee.intersects(stub)
141 {
142 pins.push(pid);
143 }
144 });
145 }
146 pins
147 }
148
149 pub fn shape_at_pos(&self, pos: Pos2) -> Option<ShapeId> {
156 self.icon_at_pos(pos)
157 .map(ShapeId::Icon)
158 .or_else(|| self.area_at_pos(pos).map(ShapeId::Area))
159 .or_else(|| self.image_at_pos(pos).map(ShapeId::Image))
160 .or_else(|| {
161 self.hit_candidates(Rect::from_min_max(pos, pos))
162 .into_iter()
163 .find_map(|(id, s)| match id {
164 ShapeId::Image(_) => None,
167 _ => s.gui_rect().contains(pos).then_some(id),
168 })
169 })
170 }
171
172 pub fn image_at_pos(&self, pos: Pos2) -> Option<ImageId> {
177 self.images_layer()
178 .filter(|(_, shape)| shape.gui_rect().contains(pos))
179 .filter_map(|(id, _)| match id {
180 ShapeId::Image(iid) => Some(iid),
181 _ => None,
182 })
183 .last()
184 }
185
186 pub fn icon_at_pos(&self, pos: Pos2) -> Option<BlockId> {
191 self.icons()
192 .filter(|(_, shape)| shape.gui_rect().contains(pos))
193 .filter_map(|(id, _)| match id {
194 ShapeId::Icon(bid) => Some(bid),
195 _ => None,
196 })
197 .last()
198 }
199
200 pub fn area_at_pos(&self, pos: Pos2) -> Option<AreaId> {
205 let mut hit = None;
206 for (id, area) in self.areas() {
207 let ShapeId::Area(cid) = id else { continue };
208 let rect = area.gui_rect();
209 let on_border =
210 rect.expand(RESIZE_SHIM).contains(pos) && !rect.shrink(RESIZE_SHIM).contains(pos);
211 let on_title = area
212 .title_anchor()
213 .is_some_and(|a| pos.distance(a) < MOVE_HOVER_DISTANCE);
214 if on_border || on_title {
215 hit = Some(cid);
216 }
217 }
218 hit
219 }
220
221 pub fn anchor_at_pos(&self, pos: Pos2) -> Option<PinId> {
222 for (_, shape) in self.hit_candidates(Rect::from_min_max(pos, pos)) {
223 let rect = shape.gui_rect();
224 let mut found: Option<PinId> = None;
225 shape.with_pins(|pin_id, _pin| {
226 if found.is_some() {
227 return;
228 }
229 let near_end = shape
232 .anchor_point_with_rect(rect, pin_id)
233 .is_some_and(|end| end.distance(pos) < ROUTE_HIT_MARGIN.get());
234 let near_stub = shape
235 .pin_stub_rect(pin_id)
236 .is_some_and(|stub| stub.expand(ROUTE_HIT_MARGIN.get()).contains(pos));
237 if near_end || near_stub {
238 found = Some(pin_id);
239 }
240 });
241 if found.is_some() {
242 return found;
243 }
244 }
245 None
246 }
247
248 pub fn anchor_targets(&self) -> Vec<(PinId, Pos2)> {
252 let mut out = Vec::new();
253 for (_, shape) in self.shapes() {
254 let rect = shape.gui_rect();
255 shape.with_pins(|pin_id, _pin| {
256 if let Some(end) = shape.anchor_point_with_rect(rect, pin_id) {
257 out.push((pin_id, end));
258 }
259 });
260 }
261 out
262 }
263
264 pub fn anchor_side(&self, anchor: PinId) -> Option<PinSide> {
267 Some(slot(self.held_pin(anchor)?).side)
268 }
269
270 pub fn route_label_at_pos(
271 &self,
272 pos: Pos2,
273 painter: &Style<'_, impl Renderer>,
274 ) -> Option<(RouteId, RouteLabelId)> {
275 self.route_candidates(Rect::from_min_max(pos, pos))
276 .into_iter()
277 .find_map(|(route_id, wire)| {
278 hit_text_anchor(
279 &wire.route.name,
280 &wire.labels,
281 self.route_geometry(route_id)?,
282 pos,
283 painter,
284 )
285 .map(|label_id| (route_id, label_id))
286 })
287 }
288
289 pub fn route_at_pos(&self, pos: Pos2) -> Option<RouteId> {
290 self.route_candidates(Rect::from_min_max(pos, pos))
291 .into_iter()
292 .filter_map(|(route_id, _)| {
293 self.route_geometry(route_id)?
294 .hovered_edge_distance(pos)
295 .map(|d| (route_id, d))
296 })
297 .min_by(|(_, a), (_, b)| a.total_cmp(b))
298 .map(|(route_id, _)| route_id)
299 }
300
301 fn pin_part_at_pos(
306 &self,
307 pos: Pos2,
308 hit_rect: impl Fn(&ShapeRef<'_>, PinId, &Pin) -> Option<Rect>,
309 ) -> Option<(PinId, PinLocation)> {
310 for (_, shape) in self.hit_candidates(Rect::from_min_max(pos, pos)) {
311 let mut found: Option<(PinId, PinLocation)> = None;
312 shape.with_pins(|pid, pin| {
313 if found.is_none()
314 && hit_rect(&shape, pid, pin).is_some_and(|rect| rect.contains(pos))
315 {
316 let slot = slot(pin);
317 found = Some((
318 pid,
319 PinLocation {
320 side: slot.side,
321 offset: slot.offset as f32 * PIN_PITCH,
322 },
323 ));
324 }
325 });
326 if found.is_some() {
327 return found;
328 }
329 }
330 None
331 }
332
333 pub fn pin_text_at_pos(
334 &self,
335 pos: Pos2,
336 painter: &Style<'_, impl Renderer>,
337 ) -> Option<(PinId, PinLocation)> {
338 self.pin_part_at_pos(pos, |shape, pid, _| {
339 Some(shape.pin_text_rect(pid, painter)?.expand(LABEL_HIT_PAD))
340 })
341 }
342
343 pub fn pin_type_at_pos(
346 &self,
347 pos: Pos2,
348 painter: &Style<'_, impl Renderer>,
349 ) -> Option<(PinId, PinLocation)> {
350 self.pin_part_at_pos(pos, |shape, pid, _| {
351 Some(shape.pin_type_rect(pid, painter)?.expand(LABEL_HIT_PAD))
352 })
353 }
354
355 pub fn pin_stub_at_pos(&self, pos: Pos2) -> Option<(PinId, PinLocation)> {
358 self.pin_part_at_pos(pos, |shape, pid, _| shape.pin_stub_rect(pid))
359 }
360
361 pub fn pin_tag_at_pos(
367 &self,
368 pos: Pos2,
369 painter: &Style<'_, impl Renderer>,
370 ) -> Option<(PinId, PinLocation)> {
371 self.pin_part_at_pos(pos, |shape, pid, pin| {
372 if pin.tag_hidden {
373 return None;
374 }
375 let tag = pin.tag.clone();
376 let text = if tag.is_empty() {
377 crate::render::ADD_TAG_PLACEHOLDER
378 } else {
379 &tag
380 };
381 Some(
382 shape
383 .tag_text_rect_for(pid, text, painter)?
384 .expand(LABEL_HIT_PAD),
385 )
386 })
387 }
388
389 pub fn title_at_pos(&self, pos: Pos2, painter: &Style<'_, impl Renderer>) -> Option<ShapeId> {
393 for (id, shape) in self
394 .areas()
395 .chain(self.hit_candidates(Rect::from_min_max(pos, pos)))
396 {
397 if let Some(title) = shape.title() {
398 let text_width = painter.text_size(title.name, &painter.theme().title_font).x;
399 let title_width = (text_width + 10.0).max(20.0);
400 let (title_pos, title_align) = crate::render::clamped_block_title_position(
403 shape.gui_rect(),
404 &title,
405 text_width,
406 );
407 let bbox = title_align.anchor_size(title_pos, vec2(title_width, TITLE_TEXT_SIZE));
408 if bbox.expand(LABEL_HIT_PAD).contains(pos) {
409 return Some(id);
410 }
411 }
412 }
413 None
414 }
415
416 pub fn title_anchor_at_pos(&self, pos: Pos2) -> Option<ShapeId> {
419 for (id, shape) in self
420 .areas()
421 .chain(self.hit_candidates(Rect::from_min_max(pos, pos)))
422 {
423 if let Some(anchor) = shape.title_anchor()
424 && pos.distance(anchor) < MOVE_HOVER_DISTANCE
425 {
426 return Some(id);
427 }
428 }
429 None
430 }
431
432 pub fn type_at_pos(&self, pos: Pos2, painter: &Style<'_, impl Renderer>) -> Option<BlockId> {
436 for (shape_id, shape) in self.hit_candidates(Rect::from_min_max(pos, pos)) {
437 let ShapeId::Rect(id) = shape_id else {
438 continue;
439 };
440 let ShapeRef::Block(block) = shape else {
441 continue;
442 };
443 if let Some(type_label) = block.type_label() {
444 let text = if type_label.name.is_empty() {
445 crate::render::ADD_TYPE_PLACEHOLDER
446 } else {
447 type_label.name
448 };
449 let text_width = painter.text_size(text, &painter.theme().type_font).x;
450 let width = (text_width + 10.0).max(20.0);
451 let (pos_anchor, align) = if type_label.name.is_empty() {
454 crate::render::block_type_position(block.gui_rect(), &type_label)
455 } else {
456 crate::render::clamped_block_type_position(
457 block.gui_rect(),
458 &type_label,
459 text_width,
460 )
461 };
462 let bbox = align.anchor_size(pos_anchor, vec2(width, TITLE_TEXT_SIZE));
463 if bbox.expand(LABEL_HIT_PAD).contains(pos) {
464 return Some(id);
465 }
466 }
467 }
468 None
469 }
470
471 pub fn anchor(&self, anchor: PinId) -> Option<Pos2> {
476 let shape = self.shape(self.pin_shape(anchor)?)?;
477 shape.anchor_point_with_rect(shape.gui_rect(), anchor)
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::path::Scope;
485 use crate::{
486 canvas::Painter,
487 theme::Theme,
488 widget::test_fixtures::{self as fx, Scene},
489 };
490 use blockworx_doc::fixtures::{block_id, image_id, pin_id};
491 use blockworx_geom::pos2;
492
493 fn with_drawing(scene: &mut Scene, check: impl Fn(&Drawing<'_>, &Style<'_, Painter>)) {
496 let ctx = egui::Context::default();
497 ctx.set_fonts(crate::canvas::build_fonts(
500 blockworx_paint::FontChoice::default(),
501 ));
502 ctx.run_ui(egui::RawInput::default(), |_| {})
503 .drop_without_applying_deltas();
504 ctx.run_ui(egui::RawInput::default(), |ui| {
505 let drawing = scene.drawing();
506 let theme = Theme::default();
507 let mut painter = Painter::headless(ui.painter().clone(), theme.palette().clone());
508 let style = Style::new(&theme, &mut painter);
509 check(&drawing, &style);
510 })
511 .drop_without_applying_deltas();
512 }
513
514 #[test]
518 fn a_clamped_title_is_hit_where_it_draws() {
519 use crate::grid::{GRID_SIZE, TITLE_TEXT_SIZE};
520 let id = block_id(1);
521 let mut scene = Scene::new(vec![
522 fx::block_in(
523 1,
524 Scope::Root,
525 Rect::from_min_max(pos2(300.0, 300.0), pos2(300.0 + 4.0 * GRID_SIZE, 420.0)),
526 ),
527 fx::titled(1, "a rather long title"),
528 fx::title_offset(1, 500.0),
529 ]);
530 with_drawing(&mut scene, |drawing, style| {
531 let (drawn_center, stale_center) = {
532 let shape = drawing.shape(ShapeId::Rect(id)).unwrap();
533 let title = shape.title().unwrap();
534 let text_width = style.text_size(title.name, &style.theme().title_font).x;
535 let (drawn, align) = crate::render::clamped_block_title_position(
536 shape.gui_rect(),
537 &title,
538 text_width,
539 );
540 let (stale, _) = crate::render::block_title_position(shape.gui_rect(), &title);
541 assert!(
544 (stale.x - drawn.x).abs() > 4.0 * GRID_SIZE,
545 "stale {stale:?} vs drawn {drawn:?}"
546 );
547 (
548 align
549 .anchor_size(drawn, vec2(text_width, TITLE_TEXT_SIZE))
550 .center(),
551 align
552 .anchor_size(stale, vec2(text_width, TITLE_TEXT_SIZE))
553 .center(),
554 )
555 };
556 assert_eq!(
557 drawing.title_at_pos(drawn_center, style),
558 Some(ShapeId::Rect(id)),
559 "the drawn title must be hittable"
560 );
561 assert_eq!(
562 drawing.title_at_pos(stale_center, style),
563 None,
564 "the stale position draws nothing and must not hit"
565 );
566 });
567 }
568
569 fn overlapping_targets() -> Scene {
573 Scene::new(vec![
574 fx::block_in(
575 1,
576 Scope::Root,
577 Rect::from_min_max(pos2(300.0, 300.0), pos2(600.0, 600.0)),
578 ),
579 fx::titled(1, "b"),
580 fx::typed(1, "T"),
581 fx::pin(3, 1, PinSide::West, 0),
582 fx::pin_typed(3, "u8"),
583 fx::pin_tagged(3, "T1"),
584 fx::pin_tag_shown(3),
585 ])
586 }
587
588 #[test]
589 fn a_title_wins_over_the_block_body_beneath_it() {
590 let rid = block_id(1);
591 with_drawing(&mut overlapping_targets(), |data, painter| {
592 let title = data
593 .shape(ShapeId::Rect(rid))
594 .unwrap()
595 .title_anchor()
596 .unwrap();
597 assert!(
598 data.shape_at_pos(title).is_some(),
599 "the probe must also be over the body, or the test proves nothing"
600 );
601 assert!(matches!(
602 data.resolve_at_pos(title, painter),
603 Some(HitTarget::Title(ShapeId::Rect(hit))) if hit == rid
604 ));
605 });
606 }
607
608 #[test]
609 fn a_pin_name_wins_over_the_block_body_beneath_it() {
610 with_drawing(&mut overlapping_targets(), |data, painter| {
611 let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
612 let probe = block.pin_text_rect(pin_id(3), painter).unwrap().center();
613 assert!(data.shape_at_pos(probe).is_some());
614 assert!(matches!(
615 data.resolve_at_pos(probe, painter),
616 Some(HitTarget::Pin {
617 part: PinPart::Name,
618 ..
619 })
620 ));
621 });
622 }
623
624 #[test]
625 fn a_pin_tag_wins_over_the_stub_it_is_drawn_over() {
626 with_drawing(&mut overlapping_targets(), |data, painter| {
627 let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
628 let tag = block.tag_text_rect_for(pin_id(3), "T1", painter).unwrap();
629 let stub = block.pin_stub_rect(pin_id(3)).unwrap();
630 let probe = tag.intersect(stub).center();
631 assert!(
632 tag.intersects(stub),
633 "the tag must overlap the stub, or the test proves nothing"
634 );
635 assert!(matches!(
636 data.resolve_at_pos(probe, painter),
637 Some(HitTarget::Pin {
638 part: PinPart::Tag,
639 ..
640 })
641 ));
642 });
643 }
644
645 #[test]
646 fn a_bare_stub_still_resolves_to_the_stub() {
647 with_drawing(&mut overlapping_targets(), |data, painter| {
648 let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
649 let stub = block.pin_stub_rect(pin_id(3)).unwrap();
650 let probe = pos2(stub.center().x, stub.bottom() - 1.0);
651 assert!(matches!(
652 data.resolve_at_pos(probe, painter),
653 Some(HitTarget::Pin {
654 part: PinPart::Stub,
655 ..
656 })
657 ));
658 });
659 }
660
661 #[test]
662 fn a_body_hit_with_nothing_over_it_resolves_to_the_shape() {
663 let rid = block_id(1);
664 with_drawing(&mut overlapping_targets(), |data, painter| {
665 let probe = data.shape(ShapeId::Rect(rid)).unwrap().gui_rect().center();
666 assert!(matches!(
667 data.resolve_at_pos(probe, painter),
668 Some(HitTarget::Shape(ShapeId::Rect(hit))) if hit == rid
669 ));
670 });
671 }
672
673 #[test]
676 fn a_port_name_wins_over_the_port_body_beneath_it() {
677 let port = pin_id(4);
678 let mut scene = Scene::new(vec![fx::pin_at(
679 4,
680 Scope::Root,
681 "Port 4",
682 fx::slot(PinSide::West, 0),
683 Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 30.0)),
684 )]);
685 with_drawing(&mut scene, |data, painter| {
686 let probe = data
687 .shape(ShapeId::Port(port))
688 .unwrap()
689 .pin_text_rect(port, painter)
690 .unwrap()
691 .center();
692 assert_eq!(data.port_at_pos(probe), Some(port));
693 assert!(matches!(
694 data.resolve_at_pos(probe, painter),
695 Some(HitTarget::Pin {
696 anchor,
697 part: PinPart::Name,
698 ..
699 }) if anchor == port
700 ));
701 });
702 }
703
704 #[test]
705 fn empty_canvas_resolves_to_nothing() {
706 with_drawing(&mut overlapping_targets(), |data, painter| {
707 assert!(data.resolve_at_pos(pos2(-500.0, -500.0), painter).is_none());
708 });
709 }
710
711 #[test]
712 fn an_image_over_a_block_is_hit_first() {
713 let sid = image_id(5);
716 let mut scene = Scene::new(vec![
717 fx::asset().1,
718 fx::block(1, 0.0), fx::image(
720 5,
721 Scope::Root,
722 Rect::from_min_max(pos2(10.0, 10.0), pos2(30.0, 30.0)),
723 ),
724 ]);
725 let drawing = scene.drawing();
726 let center = drawing
727 .shape(ShapeId::Image(sid))
728 .unwrap()
729 .gui_rect()
730 .center();
731 assert!(
732 drawing
733 .shape(ShapeId::Rect(block_id(1)))
734 .unwrap()
735 .gui_rect()
736 .contains(center),
737 "the probe must be over the block too, or the test proves nothing"
738 );
739 assert_eq!(
740 drawing.shape_at_pos(center),
741 Some(ShapeId::Image(sid)),
742 "the image wins the hit over the block beneath it"
743 );
744 }
745
746 #[test]
747 fn an_icon_is_selectable_like_any_shape() {
748 use crate::widget::spatial::SpatialIndex;
753 let a = block_id(1);
754 let icon_box = Rect::from_min_max(pos2(8.0, 8.0), pos2(32.0, 32.0));
755 let mut scene = Scene::new(vec![
756 fx::asset().1,
757 fx::block(1, 0.0),
758 fx::icon(1, icon_box),
759 ]);
760 let probe = icon_box.center();
761
762 let linear = scene.drawing().shape_at_pos(probe);
763 let index = SpatialIndex::from_drawing(&scene.drawing());
764 let Scene {
765 doc,
766 index: doc_index,
767 presentation,
768 gesture,
769 path,
770 ..
771 } = &mut scene;
772 let indexed =
773 Drawing::new_indexed(doc_index.view(doc), path, &index, presentation, gesture)
774 .shape_at_pos(probe);
775 assert_eq!(
776 linear,
777 Some(ShapeId::Icon(a)),
778 "clicking an icon selects it"
779 );
780 assert_eq!(indexed, linear, "indexed and linear hit-tests must agree");
781 }
782
783 #[test]
784 fn pins_in_rect_collects_only_enclosed_child_block_pins() {
785 let (a, pa) = (block_id(1), pin_id(3));
786 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
787 let drawing = scene.drawing();
788
789 let stub = drawing
790 .shape(ShapeId::Rect(a))
791 .unwrap()
792 .pin_stub_rect(pa)
793 .unwrap();
794 assert_eq!(drawing.pins_in_rect(stub.expand(5.0)), vec![pa]);
795 let far = Rect::from_min_size(pos2(1000.0, 1000.0), vec2(20.0, 20.0));
796 assert!(drawing.pins_in_rect(far).is_empty());
797 }
798
799 #[test]
802 fn pins_in_rect_selects_a_pin_whose_stub_is_merely_touched() {
803 let (a, pa) = (block_id(1), pin_id(3));
804 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
805 let drawing = scene.drawing();
806 let stub = drawing
807 .shape(ShapeId::Rect(a))
808 .unwrap()
809 .pin_stub_rect(pa)
810 .unwrap();
811 let touch = Rect::from_min_max(
813 pos2(stub.right() - 1.0, stub.top()),
814 pos2(stub.right() + 5.0, stub.bottom()),
815 );
816 assert!(
817 !touch.contains_rect(stub),
818 "marquee must not enclose the stub"
819 );
820 assert_eq!(drawing.pins_in_rect(touch), vec![pa]);
821 }
822
823 #[test]
826 fn anchor_at_pos_snaps_from_near_the_stub_or_a_widened_end() {
827 let (a, pa) = (block_id(1), pin_id(3));
828 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
829 let drawing = scene.drawing();
830 let (stub_center, head) = {
831 let s = drawing.shape(ShapeId::Rect(a)).unwrap();
832 (
833 s.pin_stub_rect(pa).unwrap().center(),
834 s.anchor_point_with_rect(s.gui_rect(), pa).unwrap(),
835 )
836 };
837 assert_eq!(drawing.anchor_at_pos(stub_center), Some(pa));
839 let near = head + vec2(12.0, 0.0);
841 assert!(head.distance(near) > crate::grid::HIT_RADIUS.get());
842 assert!(head.distance(near) < ROUTE_HIT_MARGIN.get());
843 assert_eq!(drawing.anchor_at_pos(near), Some(pa));
844 assert!(drawing.anchor_at_pos(pos2(500.0, 500.0)).is_none());
846 }
847}