1use blockworx_doc::id::PinId;
13use blockworx_geom::{Pos2, Rect, Vec2, pos2, vec2};
14
15use crate::{
16 edit::geometry::PinMove,
17 grid::PIN_PITCH,
18 shape::{
19 ShapeId, ShapeRef,
20 pin::{PinSide, slot},
21 },
22 theme::{Role, Style},
23 widget::drawing::Drawing,
24};
25use blockworx_paint::Renderer;
26
27const EPS: f32 = 0.5;
30
31pub const SNAP_RADIUS: f32 = 6.0;
34
35#[derive(Clone, Copy, PartialEq)]
36enum Axis {
37 Vertical,
39 Horizontal,
41}
42
43#[derive(Clone, Copy, PartialEq)]
44enum Kind {
45 Edge,
46 Center,
47 PinStub,
48}
49
50#[derive(Clone, Copy)]
51struct Candidate {
52 axis: Axis,
53 kind: Kind,
54 coord: f32,
55 lo: f32,
56 hi: f32,
57}
58
59fn push_rect_edges(rect: Rect, out: &mut Vec<Candidate>) {
62 let (t, b, l, r) = (rect.top(), rect.bottom(), rect.left(), rect.right());
63 out.push(Candidate {
64 axis: Axis::Vertical,
65 kind: Kind::Edge,
66 coord: l,
67 lo: t,
68 hi: b,
69 });
70 out.push(Candidate {
71 axis: Axis::Vertical,
72 kind: Kind::Edge,
73 coord: r,
74 lo: t,
75 hi: b,
76 });
77 out.push(Candidate {
78 axis: Axis::Horizontal,
79 kind: Kind::Edge,
80 coord: t,
81 lo: l,
82 hi: r,
83 });
84 out.push(Candidate {
85 axis: Axis::Horizontal,
86 kind: Kind::Edge,
87 coord: b,
88 lo: l,
89 hi: r,
90 });
91}
92
93fn push_rect_candidates(rect: Rect, out: &mut Vec<Candidate>) {
94 push_rect_edges(rect, out);
95 let (t, b, l, r) = (rect.top(), rect.bottom(), rect.left(), rect.right());
96 out.push(Candidate {
97 axis: Axis::Vertical,
98 kind: Kind::Center,
99 coord: rect.center().x,
100 lo: t,
101 hi: b,
102 });
103 out.push(Candidate {
104 axis: Axis::Horizontal,
105 kind: Kind::Center,
106 coord: rect.center().y,
107 lo: l,
108 hi: r,
109 });
110}
111
112fn push_point_candidates(p: Pos2, kind: Kind, out: &mut Vec<Candidate>) {
114 out.push(Candidate {
115 axis: Axis::Vertical,
116 kind,
117 coord: p.x,
118 lo: p.y,
119 hi: p.y,
120 });
121 out.push(Candidate {
122 axis: Axis::Horizontal,
123 kind,
124 coord: p.y,
125 lo: p.x,
126 hi: p.x,
127 });
128}
129
130fn push_pin_rows(shape: &ShapeRef, offset: Vec2, out: &mut Vec<Candidate>) {
136 shape.with_pins(|pid, _pin| {
137 if let Some(stub) = shape.pin_stub_rect(pid) {
138 let c = stub.center() + offset;
139 out.push(Candidate {
140 axis: Axis::Horizontal,
141 kind: Kind::PinStub,
142 coord: c.y,
143 lo: c.x,
144 hi: c.x,
145 });
146 }
147 });
148}
149
150fn shape_candidates(id: ShapeId, shape: &ShapeRef, offset: Vec2, out: &mut Vec<Candidate>) {
157 match id {
158 ShapeId::Rect(_) => {
159 push_rect_candidates(shape.gui_rect().translate(offset), out);
160 push_pin_rows(shape, offset, out);
161 }
162 ShapeId::Port(_) => {
163 push_pin_rows(shape, offset, out);
168 let r = shape.gui_rect().translate(offset);
169 for x in [r.left(), r.right()] {
170 out.push(Candidate {
171 axis: Axis::Vertical,
172 kind: Kind::Edge,
173 coord: x,
174 lo: r.top(),
175 hi: r.bottom(),
176 });
177 }
178 }
179 ShapeId::Area(_) => push_rect_candidates(shape.gui_rect().translate(offset), out),
180 ShapeId::Text(_) => {
181 push_point_candidates(
182 shape.gui_rect().translate(offset).left_top(),
183 Kind::Edge,
184 out,
185 );
186 }
187 ShapeId::Image(_) | ShapeId::Icon(_) => {
188 push_point_candidates(
189 shape.gui_rect().translate(offset).center(),
190 Kind::Center,
191 out,
192 );
193 }
194 }
195}
196
197fn match_guides(moving: &[Candidate], statics: &[Candidate]) -> Vec<[Pos2; 2]> {
198 let mut merged: Vec<(Axis, f32, f32, f32)> = Vec::new();
201 for m in moving {
202 for s in statics {
203 if m.axis != s.axis || m.kind != s.kind || (m.coord - s.coord).abs() >= EPS {
204 continue;
205 }
206 let (lo, hi) = (m.lo.min(s.lo), m.hi.max(s.hi));
207 match merged
208 .iter_mut()
209 .find(|(a, c, _, _)| *a == m.axis && (*c - m.coord).abs() < EPS)
210 {
211 Some(e) => {
212 e.2 = e.2.min(lo);
213 e.3 = e.3.max(hi);
214 }
215 None => merged.push((m.axis, m.coord, lo, hi)),
216 }
217 }
218 }
219 merged
220 .into_iter()
221 .map(|(axis, coord, lo, hi)| match axis {
222 Axis::Vertical => [pos2(coord, lo), pos2(coord, hi)],
223 Axis::Horizontal => [pos2(lo, coord), pos2(hi, coord)],
224 })
225 .collect()
226}
227
228pub fn draw_guides(guides: &[[Pos2; 2]], painter: &mut Style<'_, impl Renderer>) {
231 for &seg in guides {
232 painter.line_segment(seg, (1.0, Role::AlignmentGuide));
233 }
234}
235
236fn build_statics(data: &Drawing, is_excluded: impl Fn(ShapeId) -> bool) -> Vec<Candidate> {
240 let mut statics = Vec::new();
241 for (id, shape) in data.shapes().chain(data.areas()) {
242 if !is_excluded(id) {
243 shape_candidates(id, &shape, Vec2::ZERO, &mut statics);
244 }
245 }
246 statics
247}
248
249#[tracing::instrument(level = "info", skip_all)]
253pub fn shape_drag_guides(data: &Drawing, dragged: &[ShapeId], offset: Vec2) -> Vec<[Pos2; 2]> {
254 let mut moving = Vec::new();
255 for &id in dragged {
256 if let Some(shape) = data.shape(id) {
257 shape_candidates(id, &shape, offset, &mut moving);
258 }
259 }
260 let statics = build_statics(data, |id| dragged.contains(&id));
261 match_guides(&moving, &statics)
262}
263
264pub fn resize_guides(data: &Drawing, id: ShapeId, resized: Rect) -> Vec<[Pos2; 2]> {
268 let mut moving = Vec::new();
269 resized_candidates(id, resized, &mut moving);
270 let statics = build_statics(data, |sid| sid == id);
271 match_guides(&moving, &statics)
272}
273
274fn resized_candidates(id: ShapeId, rect: Rect, out: &mut Vec<Candidate>) {
279 match id {
280 ShapeId::Rect(_) | ShapeId::Area(_) => push_rect_candidates(rect, out),
281 ShapeId::Image(_) | ShapeId::Icon(_) => push_rect_edges(rect, out),
282 ShapeId::Port(_) => {
283 for x in [rect.left(), rect.right()] {
284 out.push(Candidate {
285 axis: Axis::Vertical,
286 kind: Kind::Edge,
287 coord: x,
288 lo: rect.top(),
289 hi: rect.bottom(),
290 });
291 }
292 }
293 ShapeId::Text(_) => {}
294 }
295}
296
297pub fn snap_resize_corner(data: &Drawing, exclude: ShapeId, corner: Pos2) -> Pos2 {
303 let statics = build_statics(data, |sid| sid == exclude);
304 let mut best_x: Option<f32> = None;
305 let mut best_y: Option<f32> = None;
306 for s in statics.iter().filter(|s| s.kind == Kind::Edge) {
307 let (cur, best) = match s.axis {
308 Axis::Vertical => (corner.x, &mut best_x),
309 Axis::Horizontal => (corner.y, &mut best_y),
310 };
311 let d = s.coord - cur;
312 if d.abs() <= SNAP_RADIUS && best.is_none_or(|b: f32| d.abs() < b.abs()) {
313 *best = Some(d);
314 }
315 }
316 pos2(
317 corner.x + best_x.unwrap_or(0.0),
318 corner.y + best_y.unwrap_or(0.0),
319 )
320}
321
322pub fn snap_drag_offset(data: &Drawing, dragged: &[ShapeId], raw_offset: Vec2) -> Vec2 {
329 let mut moving = Vec::new();
330 for &id in dragged {
331 if let Some(shape) = data.shape(id) {
332 shape_candidates(id, &shape, raw_offset, &mut moving);
333 }
334 }
335 let statics = build_statics(data, |id| dragged.contains(&id));
336 let mut best_x: Option<f32> = None;
339 let mut best_y: Option<f32> = None;
340 for m in &moving {
341 for s in &statics {
342 if m.axis != s.axis || m.kind != s.kind {
343 continue;
344 }
345 let d = s.coord - m.coord;
346 if d.abs() > SNAP_RADIUS {
347 continue;
348 }
349 let best = match m.axis {
350 Axis::Vertical => &mut best_x,
351 Axis::Horizontal => &mut best_y,
352 };
353 if best.is_none_or(|b: f32| d.abs() < b.abs()) {
354 *best = Some(d);
355 }
356 }
357 }
358 raw_offset + vec2(best_x.unwrap_or(0.0), best_y.unwrap_or(0.0))
359}
360
361pub fn pin_drag_guides(data: &Drawing, moving: &[PinMove], exclude: &[PinId]) -> Vec<[Pos2; 2]> {
365 let mut moving_c = Vec::new();
366 for &PinMove { pin: pid, to } in moving {
367 let Some(shape) = data.pin_shape(pid).and_then(|id| data.shape(id)) else {
368 continue;
369 };
370 let (Some(stub), Some(pin)) = (shape.pin_stub_rect(pid), shape.pin(pid)) else {
371 continue;
372 };
373 let rect = shape.gui_rect();
374 let half = stub.width() / 2.0;
375 let x = match to.side {
376 PinSide::East => rect.right() + half,
377 PinSide::West => rect.left() - half,
378 };
379 let y = stub.center().y + (to.offset as f32 - slot(pin).offset as f32) * PIN_PITCH;
380 moving_c.push(Candidate {
382 axis: Axis::Horizontal,
383 kind: Kind::PinStub,
384 coord: y,
385 lo: x,
386 hi: x,
387 });
388 }
389 let mut statics = Vec::new();
390 for (_, shape) in data.shapes() {
391 shape.with_pins(|pid, _pin| {
392 if !exclude.contains(&pid)
393 && let Some(stub) = shape.pin_stub_rect(pid)
394 {
395 let c = stub.center();
396 statics.push(Candidate {
397 axis: Axis::Horizontal,
398 kind: Kind::PinStub,
399 coord: c.y,
400 lo: c.x,
401 hi: c.x,
402 });
403 }
404 });
405 }
406 match_guides(&moving_c, &statics)
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::path::Scope;
413 use crate::widget::test_fixtures::{self as fx, Scene};
414 use blockworx_doc::{
415 fixtures::{block_id, image_id, pin_id},
416 geometry::PinSlot,
417 id::BlockId,
418 };
419
420 fn rect_at(x: f32, y: f32, w: f32, h: f32) -> Rect {
421 Rect::from_min_max(pos2(x, y), pos2(x + w, y + h))
422 }
423
424 fn block_at(n: u32, x: f32, y: f32, w: f32, h: f32) -> blockworx_doc::opcode::OpCodes {
425 fx::block_in(n, Scope::Root, rect_at(x, y, w, h))
426 }
427
428 fn vlines(guides: &[[Pos2; 2]]) -> Vec<f32> {
430 guides
431 .iter()
432 .filter(|g| (g[0].x - g[1].x).abs() < 0.5)
433 .map(|g| g[0].x)
434 .collect()
435 }
436 fn hlines(guides: &[[Pos2; 2]]) -> Vec<f32> {
437 guides
438 .iter()
439 .filter(|g| (g[0].y - g[1].y).abs() < 0.5)
440 .map(|g| g[0].y)
441 .collect()
442 }
443 fn has(coords: &[f32], c: f32) -> bool {
444 coords.iter().any(|&x| (x - c).abs() < 0.5)
445 }
446 fn stub_center(d: &Drawing, block: BlockId, pin: PinId) -> Pos2 {
447 d.shape(ShapeId::Rect(block))
448 .unwrap()
449 .pin_stub_rect(pin)
450 .unwrap()
451 .center()
452 }
453
454 fn rect_of(d: &Drawing, id: ShapeId) -> Rect {
455 d.shape(id).unwrap().gui_rect()
456 }
457
458 #[test]
462 fn shape_drag_guides_flags_edge_alignment() {
463 let mut scene = Scene::new(vec![
465 block_at(1, 0.0, 0.0, 45.0, 45.0),
466 block_at(2, 90.0, 150.0, 45.0, 45.0),
467 ]);
468 let drawing = scene.drawing();
469 let (a, b) = (ShapeId::Rect(block_id(1)), ShapeId::Rect(block_id(2)));
470 let (ar, br) = (rect_of(&drawing, a), rect_of(&drawing, b));
471
472 let guides = shape_drag_guides(&drawing, &[a], vec2(br.left() - ar.right(), 0.0));
474 assert!(has(&vlines(&guides), br.left()));
475 let none = shape_drag_guides(&drawing, &[a], vec2(7.0, 0.0));
477 assert!(none.is_empty());
478 }
479
480 #[test]
483 fn shape_drag_guides_flags_center_alignment() {
484 let mut scene = Scene::new(vec![
485 block_at(1, 0.0, 0.0, 30.0, 45.0),
486 block_at(2, 75.0, 150.0, 75.0, 45.0),
487 ]);
488 let drawing = scene.drawing();
489 let (a, b) = (ShapeId::Rect(block_id(1)), ShapeId::Rect(block_id(2)));
490 let (ar, br) = (rect_of(&drawing, a), rect_of(&drawing, b));
491 assert!(
492 (ar.width() - br.width()).abs() > 0.5,
493 "the blocks must differ in width, or edges could align too"
494 );
495
496 let guides = shape_drag_guides(&drawing, &[a], vec2(br.center().x - ar.center().x, 0.0));
497 assert!(has(&vlines(&guides), br.center().x));
498 }
499
500 #[test]
502 fn shape_drag_guides_flags_pin_stub_alignment() {
503 let mut scene = Scene::new(vec![
504 block_at(1, 0.0, 0.0, 40.0, 150.0),
505 fx::pin(3, 1, PinSide::East, 1),
506 block_at(2, 300.0, 0.0, 40.0, 150.0),
507 fx::pin(4, 2, PinSide::East, 1),
508 ]);
509 let drawing = scene.drawing();
510
511 let row = stub_center(&drawing, block_id(2), pin_id(4)).y;
512 let guides = shape_drag_guides(&drawing, &[ShapeId::Rect(block_id(1))], vec2(100.0, 0.0));
514 assert!(has(&hlines(&guides), row));
515 }
516
517 #[test]
520 fn shape_drag_guides_text_box_uses_only_its_corner() {
521 let mut scene = Scene::new(vec![
522 block_at(1, 0.0, 300.0, 40.0, 40.0),
523 fx::text(5, Scope::Root, "some text", pos2(100.0, 100.0)),
524 ]);
525 let drawing = scene.drawing();
526 let a = ShapeId::Rect(block_id(1));
527 let tb = rect_of(&drawing, ShapeId::Text(blockworx_doc::fixtures::text_id(5)));
528 let ar = rect_of(&drawing, a);
529
530 let g1 = shape_drag_guides(&drawing, &[a], vec2(tb.left() - ar.left(), 0.0));
532 assert!(has(&vlines(&g1), tb.left()));
533 let g2 = shape_drag_guides(&drawing, &[a], vec2(tb.right() - ar.left(), 0.0));
536 assert!(!has(&vlines(&g2), tb.right()));
537 }
538
539 #[test]
542 fn shape_drag_guides_flags_image_center_alignment() {
543 let mut scene = Scene::new(vec![
544 fx::asset().1,
545 block_at(1, 100.0, 100.0, 40.0, 40.0),
546 fx::image(5, Scope::Root, rect_at(0.0, 0.0, 25.0, 25.0)),
547 ]);
548 let drawing = scene.drawing();
549 let img = ShapeId::Image(image_id(5));
550 let ic = rect_of(&drawing, img).center();
551 let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
552
553 let guides = shape_drag_guides(&drawing, &[img], bc - ic);
554 assert!(has(&vlines(&guides), bc.x));
555 assert!(has(&hlines(&guides), bc.y));
556 }
557
558 #[test]
560 fn shape_drag_guides_flags_icon_to_block_center() {
561 let mut scene = Scene::new(vec![
563 fx::asset().1,
564 block_at(1, 0.0, 0.0, 60.0, 60.0),
565 fx::icon(1, rect_at(15.0, 15.0, 30.0, 30.0)),
566 ]);
567 let drawing = scene.drawing();
568 let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
569 let icon = ShapeId::Icon(block_id(1));
570 assert!(
571 (rect_of(&drawing, icon).center() - bc).length() < 0.5,
572 "the icon must start centered, or a zero drag proves nothing"
573 );
574
575 let guides = shape_drag_guides(&drawing, &[icon], Vec2::ZERO);
576 assert!(has(&vlines(&guides), bc.x));
577 assert!(has(&hlines(&guides), bc.y));
578 }
579
580 #[test]
583 fn snap_drag_offset_pulls_a_near_center_and_ignores_a_far_one() {
584 let mut scene = Scene::new(vec![
585 fx::asset().1,
586 block_at(1, 100.0, 100.0, 40.0, 40.0),
587 fx::image(5, Scope::Root, rect_at(0.0, 0.0, 20.0, 20.0)),
588 ]);
589 let drawing = scene.drawing();
590 let img = ShapeId::Image(image_id(5));
591 let ic = rect_of(&drawing, img).center();
592 let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
593
594 let near = (bc - ic) - vec2(3.0, 3.0);
596 let snapped = snap_drag_offset(&drawing, &[img], near);
597 assert!(
598 ((ic + snapped) - bc).length() < 1e-3,
599 "snapped onto the center"
600 );
601
602 let far = vec2(1000.0, 1000.0);
603 assert_eq!(
604 snap_drag_offset(&drawing, &[img], far),
605 far,
606 "far drag unchanged"
607 );
608 }
609
610 #[test]
613 fn pin_drag_guides_flags_a_shared_row() {
614 let mut scene = Scene::new(vec![
615 block_at(1, 0.0, 0.0, 40.0, 150.0),
616 fx::pin(3, 1, PinSide::East, 0),
617 block_at(2, 300.0, 0.0, 40.0, 150.0),
618 fx::pin(4, 2, PinSide::East, 2),
619 ]);
620 let drawing = scene.drawing();
621
622 let row = stub_center(&drawing, block_id(2), pin_id(4)).y;
623 let moved = PinMove {
625 pin: pin_id(3),
626 to: PinSlot {
627 side: PinSide::East,
628 offset: 2,
629 },
630 };
631 let guides = pin_drag_guides(&drawing, &[moved], &[pin_id(3)]);
632 assert!(has(&hlines(&guides), row));
633 }
634
635 #[test]
638 fn a_block_pin_offers_only_a_row_guide() {
639 let mut scene = Scene::new(vec![
640 block_at(1, 0.0, 0.0, 40.0, 150.0),
641 fx::pin(3, 1, PinSide::East, 1),
642 ]);
643 let drawing = scene.drawing();
644 let id = ShapeId::Rect(block_id(1));
645 let shape = drawing.shape(id).unwrap();
646
647 let mut out = Vec::new();
648 shape_candidates(id, &shape, Vec2::ZERO, &mut out);
649 let pins: Vec<&Candidate> = out.iter().filter(|c| c.kind == Kind::PinStub).collect();
650 assert!(!pins.is_empty(), "the pin contributes a row guide");
651 assert!(
652 pins.iter().all(|c| c.axis == Axis::Horizontal),
653 "a pin never offers a vertical (column) guide"
654 );
655 }
656
657 #[test]
661 fn a_port_offers_a_pin_row_and_tip_and_back_columns() {
662 let pid = pin_id(3);
663 let mut scene = Scene::new(vec![fx::pin_at(
664 3,
665 Scope::Root,
666 "x",
667 fx::slot(PinSide::East, 0),
668 rect_at(10.0, 20.0, 40.0, 30.0),
669 )]);
670 let drawing = scene.drawing();
671 let id = ShapeId::Port(pid);
672 let shape = drawing.shape(id).unwrap();
673 let r = shape.gui_rect();
674
675 let mut out = Vec::new();
676 shape_candidates(id, &shape, Vec2::ZERO, &mut out);
677 assert!(
678 out.iter()
679 .any(|c| c.kind == Kind::PinStub && c.axis == Axis::Horizontal),
680 "a horizontal guide through the pin"
681 );
682 assert!(
683 out.iter()
684 .filter(|c| c.kind == Kind::PinStub)
685 .all(|c| c.axis == Axis::Horizontal),
686 "no vertical guide through the pin"
687 );
688 let vlines: Vec<f32> = out
689 .iter()
690 .filter(|c| c.axis == Axis::Vertical)
691 .map(|c| c.coord)
692 .collect();
693 assert!(has(&vlines, r.left()), "a vertical at one pentagon end");
694 assert!(has(&vlines, r.right()), "a vertical at the other end");
695 }
696
697 #[test]
700 fn resize_guides_flag_an_edge_meeting_another() {
701 let mut scene = Scene::new(vec![
702 block_at(1, 0.0, 0.0, 45.0, 45.0),
703 block_at(2, 90.0, 150.0, 45.0, 45.0),
704 ]);
705 let drawing = scene.drawing();
706 let a = ShapeId::Rect(block_id(1));
707 let br = rect_of(&drawing, ShapeId::Rect(block_id(2)));
708
709 let onto = Rect::from_min_max(pos2(0.0, 0.0), pos2(br.left(), 45.0));
711 let guides = resize_guides(&drawing, a, onto);
712 assert!(has(&vlines(&guides), br.left()));
713
714 let off = Rect::from_min_max(pos2(0.0, 0.0), pos2(52.0, 45.0));
716 assert!(resize_guides(&drawing, a, off).is_empty());
717 }
718
719 #[test]
722 fn snap_resize_corner_snaps_a_dragged_edge_to_a_static_edge() {
723 let mut scene = Scene::new(vec![
724 fx::asset().1,
725 block_at(1, 0.0, 0.0, 45.0, 45.0),
726 fx::image(5, Scope::Root, rect_at(100.0, 100.0, 60.0, 40.0)),
727 ]);
728 let drawing = scene.drawing();
729 let img = ShapeId::Image(image_id(5));
730 let ar = rect_of(&drawing, ShapeId::Rect(block_id(1)));
731
732 let corner = pos2(ar.right() - 3.0, 500.0);
735 let snapped = snap_resize_corner(&drawing, img, corner);
736 assert!((snapped.x - ar.right()).abs() < 1e-3, "x snaps to the edge");
737 assert_eq!(snapped.y, 500.0, "y has no nearby edge");
738
739 let far = pos2(1000.0, 1000.0);
740 assert_eq!(
741 snap_resize_corner(&drawing, img, far),
742 far,
743 "far corner unchanged"
744 );
745 }
746}