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, 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 route_label_at_pos(
265 &self,
266 pos: Pos2,
267 painter: &Style<'_, impl Renderer>,
268 ) -> Option<(RouteId, RouteLabelId)> {
269 self.route_candidates(Rect::from_min_max(pos, pos))
270 .into_iter()
271 .find_map(|(route_id, wire)| {
272 hit_text_anchor(
273 &wire.route.name,
274 &wire.labels,
275 self.route_geometry(route_id)?,
276 pos,
277 painter,
278 )
279 .map(|label_id| (route_id, label_id))
280 })
281 }
282
283 pub fn route_at_pos(&self, pos: Pos2) -> Option<RouteId> {
284 self.route_candidates(Rect::from_min_max(pos, pos))
285 .into_iter()
286 .filter_map(|(route_id, _)| {
287 self.route_geometry(route_id)?
288 .hovered_edge_distance(pos)
289 .map(|d| (route_id, d))
290 })
291 .min_by(|(_, a), (_, b)| a.total_cmp(b))
292 .map(|(route_id, _)| route_id)
293 }
294
295 fn pin_part_at_pos(
300 &self,
301 pos: Pos2,
302 hit_rect: impl Fn(&ShapeRef<'_>, PinId, &Pin) -> Option<Rect>,
303 ) -> Option<(PinId, PinLocation)> {
304 for (_, shape) in self.hit_candidates(Rect::from_min_max(pos, pos)) {
305 let mut found: Option<(PinId, PinLocation)> = None;
306 shape.with_pins(|pid, pin| {
307 if found.is_none()
308 && hit_rect(&shape, pid, pin).is_some_and(|rect| rect.contains(pos))
309 {
310 let slot = slot(pin);
311 found = Some((
312 pid,
313 PinLocation {
314 side: slot.side,
315 offset: slot.offset as f32 * PIN_PITCH,
316 },
317 ));
318 }
319 });
320 if found.is_some() {
321 return found;
322 }
323 }
324 None
325 }
326
327 pub fn pin_text_at_pos(
328 &self,
329 pos: Pos2,
330 painter: &Style<'_, impl Renderer>,
331 ) -> Option<(PinId, PinLocation)> {
332 self.pin_part_at_pos(pos, |shape, pid, _| {
333 Some(shape.pin_text_rect(pid, painter)?.expand(LABEL_HIT_PAD))
334 })
335 }
336
337 pub fn pin_type_at_pos(
340 &self,
341 pos: Pos2,
342 painter: &Style<'_, impl Renderer>,
343 ) -> Option<(PinId, PinLocation)> {
344 self.pin_part_at_pos(pos, |shape, pid, _| {
345 Some(shape.pin_type_rect(pid, painter)?.expand(LABEL_HIT_PAD))
346 })
347 }
348
349 pub fn pin_stub_at_pos(&self, pos: Pos2) -> Option<(PinId, PinLocation)> {
352 self.pin_part_at_pos(pos, |shape, pid, _| shape.pin_stub_rect(pid))
353 }
354
355 pub fn pin_tag_at_pos(
361 &self,
362 pos: Pos2,
363 painter: &Style<'_, impl Renderer>,
364 ) -> Option<(PinId, PinLocation)> {
365 self.pin_part_at_pos(pos, |shape, pid, pin| {
366 if pin.tag_hidden {
367 return None;
368 }
369 let tag = pin.tag.clone();
370 let text = if tag.is_empty() {
371 crate::render::ADD_TAG_PLACEHOLDER
372 } else {
373 &tag
374 };
375 Some(
376 shape
377 .tag_text_rect_for(pid, text, painter)?
378 .expand(LABEL_HIT_PAD),
379 )
380 })
381 }
382
383 pub fn title_at_pos(&self, pos: Pos2, painter: &Style<'_, impl Renderer>) -> Option<ShapeId> {
387 for (id, shape) in self
388 .areas()
389 .chain(self.hit_candidates(Rect::from_min_max(pos, pos)))
390 {
391 if let Some(title) = shape.title() {
392 let text_width = painter.text_size(title.name, &painter.theme().title_font).x;
393 let title_width = (text_width + 10.0).max(20.0);
394 let (title_pos, title_align) = crate::render::clamped_block_title_position(
397 shape.gui_rect(),
398 &title,
399 text_width,
400 );
401 let bbox = title_align.anchor_size(title_pos, vec2(title_width, TITLE_TEXT_SIZE));
402 if bbox.expand(LABEL_HIT_PAD).contains(pos) {
403 return Some(id);
404 }
405 }
406 }
407 None
408 }
409
410 pub fn title_anchor_at_pos(&self, pos: Pos2) -> Option<ShapeId> {
413 for (id, shape) in self
414 .areas()
415 .chain(self.hit_candidates(Rect::from_min_max(pos, pos)))
416 {
417 if let Some(anchor) = shape.title_anchor()
418 && pos.distance(anchor) < MOVE_HOVER_DISTANCE
419 {
420 return Some(id);
421 }
422 }
423 None
424 }
425
426 pub fn type_at_pos(&self, pos: Pos2, painter: &Style<'_, impl Renderer>) -> Option<BlockId> {
430 for (shape_id, shape) in self.hit_candidates(Rect::from_min_max(pos, pos)) {
431 let ShapeId::Rect(id) = shape_id else {
432 continue;
433 };
434 let ShapeRef::Block(block) = shape else {
435 continue;
436 };
437 if let Some(type_label) = block.type_label() {
438 let text = if type_label.name.is_empty() {
439 crate::render::ADD_TYPE_PLACEHOLDER
440 } else {
441 type_label.name
442 };
443 let text_width = painter.text_size(text, &painter.theme().type_font).x;
444 let width = (text_width + 10.0).max(20.0);
445 let (pos_anchor, align) = if type_label.name.is_empty() {
448 crate::render::block_type_position(block.gui_rect(), &type_label)
449 } else {
450 crate::render::clamped_block_type_position(
451 block.gui_rect(),
452 &type_label,
453 text_width,
454 )
455 };
456 let bbox = align.anchor_size(pos_anchor, vec2(width, TITLE_TEXT_SIZE));
457 if bbox.expand(LABEL_HIT_PAD).contains(pos) {
458 return Some(id);
459 }
460 }
461 }
462 None
463 }
464
465 pub fn anchor(&self, anchor: PinId) -> Option<Pos2> {
470 let shape = self.shape(self.pin_shape(anchor)?)?;
471 shape.anchor_point_with_rect(shape.gui_rect(), anchor)
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::path::Scope;
479 use crate::shape::pin::PinSide;
480 use crate::{
481 theme::Theme,
482 widget::test_fixtures::{self as fx, Scene},
483 };
484 use blockworx_doc::fixtures::{block_id, image_id, pin_id};
485 use blockworx_geom::pos2;
486 use blockworx_paint::FontChoice;
487 use blockworx_text::measure::{Headless, Measured};
488
489 fn with_drawing(scene: &mut Scene, check: impl Fn(&Drawing<'_>, &Style<'_, Headless<'_>>)) {
492 let theme = Theme::default();
493 let canvas = Measured::new(FontChoice::default(), theme.palette().clone());
494 canvas.frame(|painter| {
495 let drawing = scene.drawing();
496 let style = Style::new(&theme, painter);
497 check(&drawing, &style);
498 });
499 }
500
501 #[test]
505 fn a_clamped_title_is_hit_where_it_draws() {
506 use crate::grid::{GRID_SIZE, TITLE_TEXT_SIZE};
507 let id = block_id(1);
508 let mut scene = Scene::new(vec![
509 fx::block_in(
510 1,
511 Scope::Root,
512 Rect::from_min_max(pos2(300.0, 300.0), pos2(300.0 + 4.0 * GRID_SIZE, 420.0)),
513 ),
514 fx::titled(1, "a rather long title"),
515 fx::title_offset(1, 500.0),
516 ]);
517 with_drawing(&mut scene, |drawing, style| {
518 let (drawn_center, stale_center) = {
519 let shape = drawing.shape(ShapeId::Rect(id)).unwrap();
520 let title = shape.title().unwrap();
521 let text_width = style.text_size(title.name, &style.theme().title_font).x;
522 let (drawn, align) = crate::render::clamped_block_title_position(
523 shape.gui_rect(),
524 &title,
525 text_width,
526 );
527 let (stale, _) = crate::render::block_title_position(shape.gui_rect(), &title);
528 assert!(
531 (stale.x - drawn.x).abs() > 4.0 * GRID_SIZE,
532 "stale {stale:?} vs drawn {drawn:?}"
533 );
534 (
535 align
536 .anchor_size(drawn, vec2(text_width, TITLE_TEXT_SIZE))
537 .center(),
538 align
539 .anchor_size(stale, vec2(text_width, TITLE_TEXT_SIZE))
540 .center(),
541 )
542 };
543 assert_eq!(
544 drawing.title_at_pos(drawn_center, style),
545 Some(ShapeId::Rect(id)),
546 "the drawn title must be hittable"
547 );
548 assert_eq!(
549 drawing.title_at_pos(stale_center, style),
550 None,
551 "the stale position draws nothing and must not hit"
552 );
553 });
554 }
555
556 fn overlapping_targets() -> Scene {
560 Scene::new(vec![
561 fx::block_in(
562 1,
563 Scope::Root,
564 Rect::from_min_max(pos2(300.0, 300.0), pos2(600.0, 600.0)),
565 ),
566 fx::titled(1, "b"),
567 fx::typed(1, "T"),
568 fx::pin(3, 1, PinSide::West, 0),
569 fx::pin_typed(3, "u8"),
570 fx::pin_tagged(3, "T1"),
571 fx::pin_tag_shown(3),
572 ])
573 }
574
575 #[test]
576 fn a_title_wins_over_the_block_body_beneath_it() {
577 let rid = block_id(1);
578 with_drawing(&mut overlapping_targets(), |data, painter| {
579 let title = data
580 .shape(ShapeId::Rect(rid))
581 .unwrap()
582 .title_anchor()
583 .unwrap();
584 assert!(
585 data.shape_at_pos(title).is_some(),
586 "the probe must also be over the body, or the test proves nothing"
587 );
588 assert!(matches!(
589 data.resolve_at_pos(title, painter),
590 Some(HitTarget::Title(ShapeId::Rect(hit))) if hit == rid
591 ));
592 });
593 }
594
595 #[test]
596 fn a_pin_name_wins_over_the_block_body_beneath_it() {
597 with_drawing(&mut overlapping_targets(), |data, painter| {
598 let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
599 let probe = block.pin_text_rect(pin_id(3), painter).unwrap().center();
600 assert!(data.shape_at_pos(probe).is_some());
601 assert!(matches!(
602 data.resolve_at_pos(probe, painter),
603 Some(HitTarget::Pin {
604 part: PinPart::Name,
605 ..
606 })
607 ));
608 });
609 }
610
611 #[test]
612 fn a_pin_tag_wins_over_the_stub_it_is_drawn_over() {
613 with_drawing(&mut overlapping_targets(), |data, painter| {
614 let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
615 let tag = block.tag_text_rect_for(pin_id(3), "T1", painter).unwrap();
616 let stub = block.pin_stub_rect(pin_id(3)).unwrap();
617 let probe = tag.intersect(stub).center();
618 assert!(
619 tag.intersects(stub),
620 "the tag must overlap the stub, or the test proves nothing"
621 );
622 assert!(matches!(
623 data.resolve_at_pos(probe, painter),
624 Some(HitTarget::Pin {
625 part: PinPart::Tag,
626 ..
627 })
628 ));
629 });
630 }
631
632 #[test]
633 fn a_bare_stub_still_resolves_to_the_stub() {
634 with_drawing(&mut overlapping_targets(), |data, painter| {
635 let block = data.shape(ShapeId::Rect(block_id(1))).unwrap();
636 let stub = block.pin_stub_rect(pin_id(3)).unwrap();
637 let probe = pos2(stub.center().x, stub.bottom() - 1.0);
638 assert!(matches!(
639 data.resolve_at_pos(probe, painter),
640 Some(HitTarget::Pin {
641 part: PinPart::Stub,
642 ..
643 })
644 ));
645 });
646 }
647
648 #[test]
649 fn a_body_hit_with_nothing_over_it_resolves_to_the_shape() {
650 let rid = block_id(1);
651 with_drawing(&mut overlapping_targets(), |data, painter| {
652 let probe = data.shape(ShapeId::Rect(rid)).unwrap().gui_rect().center();
653 assert!(matches!(
654 data.resolve_at_pos(probe, painter),
655 Some(HitTarget::Shape(ShapeId::Rect(hit))) if hit == rid
656 ));
657 });
658 }
659
660 #[test]
663 fn a_port_name_wins_over_the_port_body_beneath_it() {
664 let port = pin_id(4);
665 let mut scene = Scene::new(vec![fx::pin_at(
666 4,
667 Scope::Root,
668 "Port 4",
669 fx::slot(PinSide::West, 0),
670 Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 30.0)),
671 )]);
672 with_drawing(&mut scene, |data, painter| {
673 let probe = data
674 .shape(ShapeId::Port(port))
675 .unwrap()
676 .pin_text_rect(port, painter)
677 .unwrap()
678 .center();
679 assert_eq!(data.port_at_pos(probe), Some(port));
680 assert!(matches!(
681 data.resolve_at_pos(probe, painter),
682 Some(HitTarget::Pin {
683 anchor,
684 part: PinPart::Name,
685 ..
686 }) if anchor == port
687 ));
688 });
689 }
690
691 #[test]
692 fn empty_canvas_resolves_to_nothing() {
693 with_drawing(&mut overlapping_targets(), |data, painter| {
694 assert!(data.resolve_at_pos(pos2(-500.0, -500.0), painter).is_none());
695 });
696 }
697
698 #[test]
699 fn an_image_over_a_block_is_hit_first() {
700 let sid = image_id(5);
703 let mut scene = Scene::new(vec![
704 fx::asset().1,
705 fx::block(1, 0.0), fx::image(
707 5,
708 Scope::Root,
709 Rect::from_min_max(pos2(10.0, 10.0), pos2(30.0, 30.0)),
710 ),
711 ]);
712 let drawing = scene.drawing();
713 let center = drawing
714 .shape(ShapeId::Image(sid))
715 .unwrap()
716 .gui_rect()
717 .center();
718 assert!(
719 drawing
720 .shape(ShapeId::Rect(block_id(1)))
721 .unwrap()
722 .gui_rect()
723 .contains(center),
724 "the probe must be over the block too, or the test proves nothing"
725 );
726 assert_eq!(
727 drawing.shape_at_pos(center),
728 Some(ShapeId::Image(sid)),
729 "the image wins the hit over the block beneath it"
730 );
731 }
732
733 #[test]
734 fn an_icon_is_selectable_like_any_shape() {
735 use crate::widget::spatial::SpatialIndex;
740 let a = block_id(1);
741 let icon_box = Rect::from_min_max(pos2(8.0, 8.0), pos2(32.0, 32.0));
742 let mut scene = Scene::new(vec![
743 fx::asset().1,
744 fx::block(1, 0.0),
745 fx::icon(1, icon_box),
746 ]);
747 let probe = icon_box.center();
748
749 let linear = scene.drawing().shape_at_pos(probe);
750 let index = SpatialIndex::from_drawing(&scene.drawing());
751 let Scene {
752 doc,
753 index: doc_index,
754 presentation,
755 gesture,
756 path,
757 ..
758 } = &mut scene;
759 let indexed =
760 Drawing::new_indexed(doc_index.view(doc), path, &index, presentation, gesture)
761 .shape_at_pos(probe);
762 assert_eq!(
763 linear,
764 Some(ShapeId::Icon(a)),
765 "clicking an icon selects it"
766 );
767 assert_eq!(indexed, linear, "indexed and linear hit-tests must agree");
768 }
769
770 #[test]
771 fn pins_in_rect_collects_only_enclosed_child_block_pins() {
772 let (a, pa) = (block_id(1), pin_id(3));
773 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
774 let drawing = scene.drawing();
775
776 let stub = drawing
777 .shape(ShapeId::Rect(a))
778 .unwrap()
779 .pin_stub_rect(pa)
780 .unwrap();
781 assert_eq!(drawing.pins_in_rect(stub.expand(5.0)), vec![pa]);
782 let far = Rect::from_min_size(pos2(1000.0, 1000.0), vec2(20.0, 20.0));
783 assert!(drawing.pins_in_rect(far).is_empty());
784 }
785
786 #[test]
789 fn pins_in_rect_selects_a_pin_whose_stub_is_merely_touched() {
790 let (a, pa) = (block_id(1), pin_id(3));
791 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
792 let drawing = scene.drawing();
793 let stub = drawing
794 .shape(ShapeId::Rect(a))
795 .unwrap()
796 .pin_stub_rect(pa)
797 .unwrap();
798 let touch = Rect::from_min_max(
800 pos2(stub.right() - 1.0, stub.top()),
801 pos2(stub.right() + 5.0, stub.bottom()),
802 );
803 assert!(
804 !touch.contains_rect(stub),
805 "marquee must not enclose the stub"
806 );
807 assert_eq!(drawing.pins_in_rect(touch), vec![pa]);
808 }
809
810 #[test]
813 fn anchor_at_pos_snaps_from_near_the_stub_or_a_widened_end() {
814 let (a, pa) = (block_id(1), pin_id(3));
815 let mut scene = Scene::new(vec![fx::block(1, 0.0), fx::pin(3, 1, PinSide::East, 0)]);
816 let drawing = scene.drawing();
817 let (stub_center, head) = {
818 let s = drawing.shape(ShapeId::Rect(a)).unwrap();
819 (
820 s.pin_stub_rect(pa).unwrap().center(),
821 s.anchor_point_with_rect(s.gui_rect(), pa).unwrap(),
822 )
823 };
824 assert_eq!(drawing.anchor_at_pos(stub_center), Some(pa));
826 let near = head + vec2(12.0, 0.0);
828 assert!(head.distance(near) > crate::grid::HIT_RADIUS.get());
829 assert!(head.distance(near) < ROUTE_HIT_MARGIN.get());
830 assert_eq!(drawing.anchor_at_pos(near), Some(pa));
831 assert!(drawing.anchor_at_pos(pos2(500.0, 500.0)).is_none());
833 }
834}