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
31const 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
249pub fn shape_drag_guides(data: &Drawing, dragged: &[ShapeId], offset: Vec2) -> Vec<[Pos2; 2]> {
253 let mut moving = Vec::new();
254 for &id in dragged {
255 if let Some(shape) = data.shape(id) {
256 shape_candidates(id, &shape, offset, &mut moving);
257 }
258 }
259 let statics = build_statics(data, |id| dragged.contains(&id));
260 match_guides(&moving, &statics)
261}
262
263pub fn resize_guides(data: &Drawing, id: ShapeId, resized: Rect) -> Vec<[Pos2; 2]> {
267 let mut moving = Vec::new();
268 resized_candidates(id, resized, &mut moving);
269 let statics = build_statics(data, |sid| sid == id);
270 match_guides(&moving, &statics)
271}
272
273fn resized_candidates(id: ShapeId, rect: Rect, out: &mut Vec<Candidate>) {
278 match id {
279 ShapeId::Rect(_) | ShapeId::Area(_) => push_rect_candidates(rect, out),
280 ShapeId::Image(_) | ShapeId::Icon(_) => push_rect_edges(rect, out),
281 ShapeId::Port(_) => {
282 for x in [rect.left(), rect.right()] {
283 out.push(Candidate {
284 axis: Axis::Vertical,
285 kind: Kind::Edge,
286 coord: x,
287 lo: rect.top(),
288 hi: rect.bottom(),
289 });
290 }
291 }
292 ShapeId::Text(_) => {}
293 }
294}
295
296pub fn snap_resize_corner(data: &Drawing, exclude: ShapeId, corner: Pos2) -> Pos2 {
302 let statics = build_statics(data, |sid| sid == exclude);
303 let mut best_x: Option<f32> = None;
304 let mut best_y: Option<f32> = None;
305 for s in statics.iter().filter(|s| s.kind == Kind::Edge) {
306 let (cur, best) = match s.axis {
307 Axis::Vertical => (corner.x, &mut best_x),
308 Axis::Horizontal => (corner.y, &mut best_y),
309 };
310 let d = s.coord - cur;
311 if d.abs() <= SNAP_RADIUS && best.is_none_or(|b: f32| d.abs() < b.abs()) {
312 *best = Some(d);
313 }
314 }
315 pos2(
316 corner.x + best_x.unwrap_or(0.0),
317 corner.y + best_y.unwrap_or(0.0),
318 )
319}
320
321pub fn snap_drag_offset(data: &Drawing, dragged: &[ShapeId], raw_offset: Vec2) -> Vec2 {
328 let mut moving = Vec::new();
329 for &id in dragged {
330 if let Some(shape) = data.shape(id) {
331 shape_candidates(id, &shape, raw_offset, &mut moving);
332 }
333 }
334 let statics = build_statics(data, |id| dragged.contains(&id));
335 let mut best_x: Option<f32> = None;
338 let mut best_y: Option<f32> = None;
339 for m in &moving {
340 for s in &statics {
341 if m.axis != s.axis || m.kind != s.kind {
342 continue;
343 }
344 let d = s.coord - m.coord;
345 if d.abs() > SNAP_RADIUS {
346 continue;
347 }
348 let best = match m.axis {
349 Axis::Vertical => &mut best_x,
350 Axis::Horizontal => &mut best_y,
351 };
352 if best.is_none_or(|b: f32| d.abs() < b.abs()) {
353 *best = Some(d);
354 }
355 }
356 }
357 raw_offset + vec2(best_x.unwrap_or(0.0), best_y.unwrap_or(0.0))
358}
359
360pub fn pin_drag_guides(data: &Drawing, moving: &[PinMove], exclude: &[PinId]) -> Vec<[Pos2; 2]> {
364 let mut moving_c = Vec::new();
365 for &PinMove { pin: pid, to } in moving {
366 let Some(shape) = data.pin_shape(pid).and_then(|id| data.shape(id)) else {
367 continue;
368 };
369 let (Some(stub), Some(pin)) = (shape.pin_stub_rect(pid), shape.pin(pid)) else {
370 continue;
371 };
372 let rect = shape.gui_rect();
373 let half = stub.width() / 2.0;
374 let x = match to.side {
375 PinSide::East => rect.right() + half,
376 PinSide::West => rect.left() - half,
377 };
378 let y = stub.center().y + (to.offset as f32 - slot(pin).offset as f32) * PIN_PITCH;
379 moving_c.push(Candidate {
381 axis: Axis::Horizontal,
382 kind: Kind::PinStub,
383 coord: y,
384 lo: x,
385 hi: x,
386 });
387 }
388 let mut statics = Vec::new();
389 for (_, shape) in data.shapes() {
390 shape.with_pins(|pid, _pin| {
391 if !exclude.contains(&pid)
392 && let Some(stub) = shape.pin_stub_rect(pid)
393 {
394 let c = stub.center();
395 statics.push(Candidate {
396 axis: Axis::Horizontal,
397 kind: Kind::PinStub,
398 coord: c.y,
399 lo: c.x,
400 hi: c.x,
401 });
402 }
403 });
404 }
405 match_guides(&moving_c, &statics)
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use crate::path::Scope;
412 use crate::widget::test_fixtures::{self as fx, Scene};
413 use blockworx_doc::{
414 fixtures::{block_id, image_id, pin_id},
415 geometry::PinSlot,
416 id::BlockId,
417 };
418
419 fn rect_at(x: f32, y: f32, w: f32, h: f32) -> Rect {
420 Rect::from_min_max(pos2(x, y), pos2(x + w, y + h))
421 }
422
423 fn block_at(n: u32, x: f32, y: f32, w: f32, h: f32) -> blockworx_doc::opcode::OpCodes {
424 fx::block_in(n, Scope::Root, rect_at(x, y, w, h))
425 }
426
427 fn vlines(guides: &[[Pos2; 2]]) -> Vec<f32> {
429 guides
430 .iter()
431 .filter(|g| (g[0].x - g[1].x).abs() < 0.5)
432 .map(|g| g[0].x)
433 .collect()
434 }
435 fn hlines(guides: &[[Pos2; 2]]) -> Vec<f32> {
436 guides
437 .iter()
438 .filter(|g| (g[0].y - g[1].y).abs() < 0.5)
439 .map(|g| g[0].y)
440 .collect()
441 }
442 fn has(coords: &[f32], c: f32) -> bool {
443 coords.iter().any(|&x| (x - c).abs() < 0.5)
444 }
445 fn stub_center(d: &Drawing, block: BlockId, pin: PinId) -> Pos2 {
446 d.shape(ShapeId::Rect(block))
447 .unwrap()
448 .pin_stub_rect(pin)
449 .unwrap()
450 .center()
451 }
452
453 fn rect_of(d: &Drawing, id: ShapeId) -> Rect {
454 d.shape(id).unwrap().gui_rect()
455 }
456
457 #[test]
461 fn shape_drag_guides_flags_edge_alignment() {
462 let mut scene = Scene::new(vec![
464 block_at(1, 0.0, 0.0, 45.0, 45.0),
465 block_at(2, 90.0, 150.0, 45.0, 45.0),
466 ]);
467 let drawing = scene.drawing();
468 let (a, b) = (ShapeId::Rect(block_id(1)), ShapeId::Rect(block_id(2)));
469 let (ar, br) = (rect_of(&drawing, a), rect_of(&drawing, b));
470
471 let guides = shape_drag_guides(&drawing, &[a], vec2(br.left() - ar.right(), 0.0));
473 assert!(has(&vlines(&guides), br.left()));
474 let none = shape_drag_guides(&drawing, &[a], vec2(7.0, 0.0));
476 assert!(none.is_empty());
477 }
478
479 #[test]
482 fn shape_drag_guides_flags_center_alignment() {
483 let mut scene = Scene::new(vec![
484 block_at(1, 0.0, 0.0, 30.0, 45.0),
485 block_at(2, 75.0, 150.0, 75.0, 45.0),
486 ]);
487 let drawing = scene.drawing();
488 let (a, b) = (ShapeId::Rect(block_id(1)), ShapeId::Rect(block_id(2)));
489 let (ar, br) = (rect_of(&drawing, a), rect_of(&drawing, b));
490 assert!(
491 (ar.width() - br.width()).abs() > 0.5,
492 "the blocks must differ in width, or edges could align too"
493 );
494
495 let guides = shape_drag_guides(&drawing, &[a], vec2(br.center().x - ar.center().x, 0.0));
496 assert!(has(&vlines(&guides), br.center().x));
497 }
498
499 #[test]
501 fn shape_drag_guides_flags_pin_stub_alignment() {
502 let mut scene = Scene::new(vec![
503 block_at(1, 0.0, 0.0, 40.0, 150.0),
504 fx::pin(3, 1, PinSide::East, 1),
505 block_at(2, 300.0, 0.0, 40.0, 150.0),
506 fx::pin(4, 2, PinSide::East, 1),
507 ]);
508 let drawing = scene.drawing();
509
510 let row = stub_center(&drawing, block_id(2), pin_id(4)).y;
511 let guides = shape_drag_guides(&drawing, &[ShapeId::Rect(block_id(1))], vec2(100.0, 0.0));
513 assert!(has(&hlines(&guides), row));
514 }
515
516 #[test]
519 fn shape_drag_guides_text_box_uses_only_its_corner() {
520 let mut scene = Scene::new(vec![
521 block_at(1, 0.0, 300.0, 40.0, 40.0),
522 fx::text(5, Scope::Root, "some text", pos2(100.0, 100.0)),
523 ]);
524 let drawing = scene.drawing();
525 let a = ShapeId::Rect(block_id(1));
526 let tb = rect_of(&drawing, ShapeId::Text(blockworx_doc::fixtures::text_id(5)));
527 let ar = rect_of(&drawing, a);
528
529 let g1 = shape_drag_guides(&drawing, &[a], vec2(tb.left() - ar.left(), 0.0));
531 assert!(has(&vlines(&g1), tb.left()));
532 let g2 = shape_drag_guides(&drawing, &[a], vec2(tb.right() - ar.left(), 0.0));
535 assert!(!has(&vlines(&g2), tb.right()));
536 }
537
538 #[test]
541 fn shape_drag_guides_flags_image_center_alignment() {
542 let mut scene = Scene::new(vec![
543 fx::asset().1,
544 block_at(1, 100.0, 100.0, 40.0, 40.0),
545 fx::image(5, Scope::Root, rect_at(0.0, 0.0, 25.0, 25.0)),
546 ]);
547 let drawing = scene.drawing();
548 let img = ShapeId::Image(image_id(5));
549 let ic = rect_of(&drawing, img).center();
550 let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
551
552 let guides = shape_drag_guides(&drawing, &[img], bc - ic);
553 assert!(has(&vlines(&guides), bc.x));
554 assert!(has(&hlines(&guides), bc.y));
555 }
556
557 #[test]
559 fn shape_drag_guides_flags_icon_to_block_center() {
560 let mut scene = Scene::new(vec![
562 fx::asset().1,
563 block_at(1, 0.0, 0.0, 60.0, 60.0),
564 fx::icon(1, rect_at(15.0, 15.0, 30.0, 30.0)),
565 ]);
566 let drawing = scene.drawing();
567 let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
568 let icon = ShapeId::Icon(block_id(1));
569 assert!(
570 (rect_of(&drawing, icon).center() - bc).length() < 0.5,
571 "the icon must start centered, or a zero drag proves nothing"
572 );
573
574 let guides = shape_drag_guides(&drawing, &[icon], Vec2::ZERO);
575 assert!(has(&vlines(&guides), bc.x));
576 assert!(has(&hlines(&guides), bc.y));
577 }
578
579 #[test]
582 fn snap_drag_offset_pulls_a_near_center_and_ignores_a_far_one() {
583 let mut scene = Scene::new(vec![
584 fx::asset().1,
585 block_at(1, 100.0, 100.0, 40.0, 40.0),
586 fx::image(5, Scope::Root, rect_at(0.0, 0.0, 20.0, 20.0)),
587 ]);
588 let drawing = scene.drawing();
589 let img = ShapeId::Image(image_id(5));
590 let ic = rect_of(&drawing, img).center();
591 let bc = rect_of(&drawing, ShapeId::Rect(block_id(1))).center();
592
593 let near = (bc - ic) - vec2(3.0, 3.0);
595 let snapped = snap_drag_offset(&drawing, &[img], near);
596 assert!(
597 ((ic + snapped) - bc).length() < 1e-3,
598 "snapped onto the center"
599 );
600
601 let far = vec2(1000.0, 1000.0);
602 assert_eq!(
603 snap_drag_offset(&drawing, &[img], far),
604 far,
605 "far drag unchanged"
606 );
607 }
608
609 #[test]
612 fn pin_drag_guides_flags_a_shared_row() {
613 let mut scene = Scene::new(vec![
614 block_at(1, 0.0, 0.0, 40.0, 150.0),
615 fx::pin(3, 1, PinSide::East, 0),
616 block_at(2, 300.0, 0.0, 40.0, 150.0),
617 fx::pin(4, 2, PinSide::East, 2),
618 ]);
619 let drawing = scene.drawing();
620
621 let row = stub_center(&drawing, block_id(2), pin_id(4)).y;
622 let moved = PinMove {
624 pin: pin_id(3),
625 to: PinSlot {
626 side: PinSide::East,
627 offset: 2,
628 },
629 };
630 let guides = pin_drag_guides(&drawing, &[moved], &[pin_id(3)]);
631 assert!(has(&hlines(&guides), row));
632 }
633
634 #[test]
637 fn a_block_pin_offers_only_a_row_guide() {
638 let mut scene = Scene::new(vec![
639 block_at(1, 0.0, 0.0, 40.0, 150.0),
640 fx::pin(3, 1, PinSide::East, 1),
641 ]);
642 let drawing = scene.drawing();
643 let id = ShapeId::Rect(block_id(1));
644 let shape = drawing.shape(id).unwrap();
645
646 let mut out = Vec::new();
647 shape_candidates(id, &shape, Vec2::ZERO, &mut out);
648 let pins: Vec<&Candidate> = out.iter().filter(|c| c.kind == Kind::PinStub).collect();
649 assert!(!pins.is_empty(), "the pin contributes a row guide");
650 assert!(
651 pins.iter().all(|c| c.axis == Axis::Horizontal),
652 "a pin never offers a vertical (column) guide"
653 );
654 }
655
656 #[test]
660 fn a_port_offers_a_pin_row_and_tip_and_back_columns() {
661 let pid = pin_id(3);
662 let mut scene = Scene::new(vec![fx::pin_at(
663 3,
664 Scope::Root,
665 "x",
666 fx::slot(PinSide::East, 0),
667 rect_at(10.0, 20.0, 40.0, 30.0),
668 )]);
669 let drawing = scene.drawing();
670 let id = ShapeId::Port(pid);
671 let shape = drawing.shape(id).unwrap();
672 let r = shape.gui_rect();
673
674 let mut out = Vec::new();
675 shape_candidates(id, &shape, Vec2::ZERO, &mut out);
676 assert!(
677 out.iter()
678 .any(|c| c.kind == Kind::PinStub && c.axis == Axis::Horizontal),
679 "a horizontal guide through the pin"
680 );
681 assert!(
682 out.iter()
683 .filter(|c| c.kind == Kind::PinStub)
684 .all(|c| c.axis == Axis::Horizontal),
685 "no vertical guide through the pin"
686 );
687 let vlines: Vec<f32> = out
688 .iter()
689 .filter(|c| c.axis == Axis::Vertical)
690 .map(|c| c.coord)
691 .collect();
692 assert!(has(&vlines, r.left()), "a vertical at one pentagon end");
693 assert!(has(&vlines, r.right()), "a vertical at the other end");
694 }
695
696 #[test]
699 fn resize_guides_flag_an_edge_meeting_another() {
700 let mut scene = Scene::new(vec![
701 block_at(1, 0.0, 0.0, 45.0, 45.0),
702 block_at(2, 90.0, 150.0, 45.0, 45.0),
703 ]);
704 let drawing = scene.drawing();
705 let a = ShapeId::Rect(block_id(1));
706 let br = rect_of(&drawing, ShapeId::Rect(block_id(2)));
707
708 let onto = Rect::from_min_max(pos2(0.0, 0.0), pos2(br.left(), 45.0));
710 let guides = resize_guides(&drawing, a, onto);
711 assert!(has(&vlines(&guides), br.left()));
712
713 let off = Rect::from_min_max(pos2(0.0, 0.0), pos2(52.0, 45.0));
715 assert!(resize_guides(&drawing, a, off).is_empty());
716 }
717
718 #[test]
721 fn snap_resize_corner_snaps_a_dragged_edge_to_a_static_edge() {
722 let mut scene = Scene::new(vec![
723 fx::asset().1,
724 block_at(1, 0.0, 0.0, 45.0, 45.0),
725 fx::image(5, Scope::Root, rect_at(100.0, 100.0, 60.0, 40.0)),
726 ]);
727 let drawing = scene.drawing();
728 let img = ShapeId::Image(image_id(5));
729 let ar = rect_of(&drawing, ShapeId::Rect(block_id(1)));
730
731 let corner = pos2(ar.right() - 3.0, 500.0);
734 let snapped = snap_resize_corner(&drawing, img, corner);
735 assert!((snapped.x - ar.right()).abs() < 1e-3, "x snaps to the edge");
736 assert_eq!(snapped.y, 500.0, "y has no nearby edge");
737
738 let far = pos2(1000.0, 1000.0);
739 assert_eq!(
740 snap_resize_corner(&drawing, img, far),
741 far,
742 "far corner unchanged"
743 );
744 }
745}