1use std::time::Duration;
14
15use blockworx_doc::id::BlockId;
16use blockworx_geom::WorldPx;
17use blockworx_geom::{Pos2, Vec2, vec2};
18
19use crate::progress::Progress;
20use crate::{
21 grid::{GRID_SIZE, HIT_RADIUS, PIN_PITCH, PIN_TOP_MARGIN, PORT_RADIUS, ROUTE_HIT_MARGIN},
22 shape::{BaseShape, BlockShape, PinLocation, pin::PinSide},
23 theme::{Role, Style},
24};
25use blockworx_paint::{AnimKey, Canvas, Renderer};
26pub(crate) use crate::render::{NEW_PIN_ANIM_TIME, NEW_PIN_GROW_RANGE, NEW_PIN_INACTIVE_SCALE};
29
30pub(crate) const NEW_PIN_ACTIVATION_RANGE: WorldPx = WorldPx::new(3.0 * GRID_SIZE);
32pub(crate) fn new_pin_grab() -> WorldPx {
43 WorldPx::new(clearances().into_iter().fold(f32::INFINITY, f32::min))
44}
45
46fn clearances() -> [f32; 5] {
49 [
50 GRID_SIZE,
53 PIN_PITCH * 0.5,
56 GRID_SIZE.hypot(PIN_TOP_MARGIN) - HIT_RADIUS.get(),
60 PIN_PITCH - NEW_PIN_GROW_RANGE.get(),
63 PIN_PITCH - (GRID_SIZE / 3.0 + ROUTE_HIT_MARGIN.get()),
65 ]
66}
67
68pub(crate) const HINT_DRIFT: f32 = 2.0 * GRID_SIZE;
70pub(crate) const HINT_PERIOD: Duration = Duration::from_millis(900);
72pub(crate) const HINT_ROUTE_THRESHOLD: WorldPx = WorldPx::new(1.5 * GRID_SIZE);
76
77pub(crate) fn route_hint_phase(now: Duration) -> Progress {
81 Progress::new((now.as_secs_f64() / HINT_PERIOD.as_secs_f64()).rem_euclid(1.0) as f32)
82}
83
84pub(crate) fn new_pin_targets(block: &BlockShape<'_>) -> Vec<(PinLocation, Pos2)> {
87 block
88 .new_pin_locations()
89 .into_iter()
90 .filter_map(|loc| {
91 let edge = block.pin_position(loc)?;
92 let outward = match loc.side {
93 PinSide::East => GRID_SIZE,
94 PinSide::West => -GRID_SIZE,
95 };
96 Some((loc, edge + vec2(outward, 0.0)))
97 })
98 .collect()
99}
100
101pub(crate) fn active_new_pin_target(targets: &[(PinLocation, Pos2)], pos: Pos2) -> Option<usize> {
104 targets
105 .iter()
106 .enumerate()
107 .map(|(i, (_, p))| (i, p.distance(pos)))
108 .filter(|(_, d)| *d < NEW_PIN_ACTIVATION_RANGE.get())
109 .min_by(|a, b| a.1.total_cmp(&b.1))
110 .map(|(i, _)| i)
111}
112
113pub(crate) fn draw_plus<R: Renderer>(
115 center: Pos2,
116 radius: WorldPx,
117 role: Role,
118 painter: &mut Style<'_, R>,
119) {
120 let arm = radius.get() * 0.6;
121 painter.line_segment(
122 [center - vec2(arm, 0.0), center + vec2(arm, 0.0)],
123 (2.0, role),
124 );
125 painter.line_segment(
126 [center - vec2(0.0, arm), center + vec2(0.0, arm)],
127 (2.0, role),
128 );
129}
130
131pub(crate) fn hint_offset(side: PinSide, phase: f32) -> Vec2 {
134 let outward = match side {
135 PinSide::East => 1.0,
136 PinSide::West => -1.0,
137 };
138 vec2(outward * phase * HINT_DRIFT, 0.0)
139}
140
141const HINT_GHOSTS: usize = 3;
143
144pub(crate) fn draw_route_hint<C: Canvas>(
150 center: Pos2,
151 side: PinSide,
152 phase: Progress,
153 painter: &mut Style<'_, C>,
154) {
155 let outward = match side {
156 PinSide::East => 1.0,
157 PinSide::West => -1.0,
158 };
159 let tip = center + vec2(outward * (HINT_DRIFT + PORT_RADIUS.get()), 0.0);
161 let barb = PORT_RADIUS.get() * 0.7;
162 painter.line_segment(
163 [tip, tip - vec2(outward * barb, barb)],
164 (2.0, Role::RouteStartTarget),
165 );
166 painter.line_segment(
167 [tip, tip - vec2(outward * barb, -barb)],
168 (2.0, Role::RouteStartTarget),
169 );
170 for k in 0..HINT_GHOSTS {
173 let p = (phase.get() + k as f32 / HINT_GHOSTS as f32).fract();
174 let ghost = center + hint_offset(side, p);
175 let opacity = (p * std::f32::consts::PI).sin();
176 painter.with_opacity(opacity, |painter| {
177 painter.circle_filled(ghost, PORT_RADIUS * 0.55, Role::RouteStartTarget);
178 });
179 }
180}
181
182pub(crate) fn target_anim_key(rid: BlockId, loc: PinLocation) -> AnimKey {
185 let slot = (loc.offset / PIN_PITCH).round() as i32;
186 AnimKey::of(("new_pin_target", rid, loc.side, slot))
187}
188
189fn route_target_anim_key(rid: BlockId, loc: PinLocation) -> AnimKey {
192 let slot = (loc.offset / PIN_PITCH).round() as i32;
193 AnimKey::of(("route_new_pin_target", rid, loc.side, slot))
194}
195
196pub(crate) fn draw_route_new_pin_targets<C: Canvas>(
201 rid: BlockId,
202 targets: &[(PinLocation, Pos2)],
203 pointer: Option<Pos2>,
204 painter: &mut Style<'_, C>,
205) -> Option<(PinLocation, Pos2)> {
206 let nearest = pointer.and_then(|p| active_new_pin_target(targets, p));
207 let mut armed = None;
208 for (i, (loc, center)) in targets.iter().enumerate() {
209 let dist = pointer.map(|p| p.distance(*center));
210 if !dist.is_some_and(|d| d < NEW_PIN_ACTIVATION_RANGE.get()) {
211 painter.animate(route_target_anim_key(rid, *loc), 0.0, NEW_PIN_ANIM_TIME);
214 continue;
215 }
216 let active = nearest == Some(i) && dist.is_some_and(|d| d < NEW_PIN_GROW_RANGE.get());
217 if active {
218 armed = Some((*loc, *center));
219 }
220 let goal = if active { 1.0 } else { 0.0 };
221 let t = painter.animate(route_target_anim_key(rid, *loc), goal, NEW_PIN_ANIM_TIME);
222 if t < 1.0 {
225 painter.with_opacity(1.0 - t, |p| {
226 p.circle_filled(
227 *center,
228 PORT_RADIUS * NEW_PIN_INACTIVE_SCALE,
229 Role::NewPinPreviewFill,
230 );
231 });
232 }
233 if t > 0.0 {
234 let radius =
235 PORT_RADIUS * (NEW_PIN_INACTIVE_SCALE + (1.0 - NEW_PIN_INACTIVE_SCALE) * t);
236 painter.circle(
237 *center,
238 radius,
239 Role::Transparent,
240 (2.0, Role::NewPinPreviewFill),
241 );
242 painter.with_opacity(t, |p| {
243 draw_plus(*center, PORT_RADIUS, Role::NewPinPreviewFill, p);
244 });
245 }
246 }
247 armed
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::path::Scope;
254 use crate::widget::{
255 drawing::Drawing,
256 test_fixtures::{self as fx, Scene},
257 };
258 use blockworx_doc::fixtures::block_id;
259 use blockworx_geom::{Rect, pos2};
260
261 fn scene_300() -> Scene {
263 Scene::new(vec![
264 fx::block_in(
265 1,
266 Scope::Root,
267 Rect::from_min_max(pos2(0.0, 0.0), pos2(300.0, 300.0)),
268 ),
269 fx::titled(1, "b"),
270 ])
271 }
272
273 fn block_300<'a>(drawing: &'a Drawing<'_>) -> BlockShape<'a> {
275 drawing
276 .block_shape(block_id(1))
277 .expect("the block is in this scope")
278 }
279
280 #[test]
283 fn the_add_pin_grab_is_the_widest_radius_its_neighbours_leave_it() {
284 let grab = new_pin_grab().get();
285 assert!(
286 grab >= GRID_SIZE,
287 "the grab is under a grid unit: {grab} against {GRID_SIZE}",
288 );
289 assert!(
290 grab > PORT_RADIUS.get(),
291 "precondition: the grab is wider than the ring it draws \
292 ({grab} against {})",
293 PORT_RADIUS.get(),
294 );
295 for (n, clearance) in clearances().into_iter().enumerate() {
296 assert!(
297 grab <= clearance,
298 "the grab of {grab} overlaps neighbour {n}, which leaves {clearance}",
299 );
300 }
301 assert!(
302 clearances().into_iter().any(|room| room == grab),
303 "the grab is smaller than every clearance, so it is not maximal: \
304 {grab} against {:?}",
305 clearances(),
306 );
307 }
308
309 #[test]
314 fn the_add_pin_grab_touches_nothing_on_a_real_block() {
315 let mut scene = scene_300();
316 let drawing = scene.drawing();
317 let block = block_300(&drawing);
318 let bbox = block.gui_rect();
319 let grab = new_pin_grab().get();
320 let east: Vec<Pos2> = new_pin_targets(&block)
321 .into_iter()
322 .filter(|(loc, _)| loc.side == PinSide::East)
323 .map(|(_, at)| at)
324 .collect();
325 assert!(
326 east.len() >= 2,
327 "precondition: the block has two free slots to crowd each other",
328 );
329
330 for at in &east {
331 assert!(
332 at.x - grab >= bbox.right() - 1e-3,
333 "the marker's grab reaches inside the block: {at:?} against {bbox:?}",
334 );
335 for corner in [bbox.right_top(), bbox.right_bottom()] {
336 assert!(
337 at.distance(corner) >= grab + HIT_RADIUS.get() - 1e-3,
338 "the marker at {at:?} overlaps the resize handle at {corner:?}",
339 );
340 }
341 }
342 for pair in east.windows(2) {
343 assert!(
344 pair[0].distance(pair[1]) >= 2.0 * grab - 1e-3,
345 "two markers' grabs overlap: {:?} and {:?}",
346 pair[0],
347 pair[1],
348 );
349 }
350 }
351
352 #[test]
353 fn new_pin_targets_sit_one_grid_cell_outside_each_edge() {
354 let mut scene = scene_300();
355 let drawing = scene.drawing();
356 let block = block_300(&drawing);
357 let bbox = block.gui_rect();
358 let targets = new_pin_targets(&block);
359 assert!(!targets.is_empty());
360
361 let (mut west, mut east) = (0, 0);
362 for (loc, p) in &targets {
363 match loc.side {
364 PinSide::West => {
365 assert!((p.x - (bbox.left() - GRID_SIZE)).abs() < 1e-3);
366 west += 1;
367 }
368 PinSide::East => {
369 assert!((p.x - (bbox.right() + GRID_SIZE)).abs() < 1e-3);
370 east += 1;
371 }
372 }
373 }
374 assert!(west > 0 && east > 0);
376 }
377
378 #[test]
379 fn active_new_pin_target_is_the_nearest_within_range_else_none() {
380 let mut scene = scene_300();
381 let drawing = scene.drawing();
382 let targets = new_pin_targets(&block_300(&drawing));
383
384 let (_, first) = targets[0];
386 assert_eq!(active_new_pin_target(&targets, first), Some(0));
387
388 let near = first + vec2(NEW_PIN_ACTIVATION_RANGE.get() * 0.5, 0.0);
391 assert_eq!(active_new_pin_target(&targets, near), Some(0));
392
393 let far = pos2(10_000.0, 10_000.0);
395 assert_eq!(active_new_pin_target(&targets, far), None);
396 }
397
398 #[test]
399 fn hint_offset_drifts_outward_along_the_pin_side() {
400 assert_eq!(hint_offset(PinSide::East, 0.0), vec2(0.0, 0.0));
402 assert!(hint_offset(PinSide::East, 0.5).x > 0.0);
404 assert!(hint_offset(PinSide::West, 0.5).x < 0.0);
405 assert_eq!(hint_offset(PinSide::East, 0.5).y, 0.0);
406 assert!(hint_offset(PinSide::East, 0.9).x > hint_offset(PinSide::East, 0.4).x);
408 assert_eq!(hint_offset(PinSide::East, 1.0).x, HINT_DRIFT);
409 assert_eq!(hint_offset(PinSide::West, 1.0).x, -HINT_DRIFT);
410 }
411}