1use core::time::Duration;
11
12use blockworx_doc::geometry::{GridPoint, GridRect, GridSize, PinSlot, ScreenRect};
13use blockworx_doc::id::{AreaId, BlockId, EntityRef, ImageId, PinId, RouteId, TextId};
14use blockworx_doc::values::PinSide;
15pub use blockworx_store::worked::Step;
16use blockworx_store::worked::{Worked, scope_of};
17
18use crate::grid::{GRID_SIZE, artwork_rect, pin_slot_row, px_rect};
19use crate::path::Scope;
20use crate::presentation::RouteGeometries;
21use crate::theme::{Role, Style};
22use blockworx_geom::WorldPx;
23use blockworx_paint::Canvas;
24
25#[derive(Clone, Copy, PartialEq, Eq, Debug)]
28pub struct Spotlight {
29 pub scope: Scope,
30 pub region: GridRect,
31}
32
33pub fn worked(step: Step<'_>, routes: &RouteGeometries, worked: &Worked) -> Option<Spotlight> {
46 let scope = Scope::from_wire(worked.scope);
47 let region = worked
48 .touched
49 .iter()
50 .filter(|subject| in_scope(step, **subject, scope))
51 .filter_map(|subject| region_of(step, routes, *subject))
52 .reduce(union)?;
53 Some(Spotlight { scope, region })
54}
55
56fn in_scope(step: Step<'_>, subject: EntityRef, scope: Scope) -> bool {
58 scope_of(step, subject).is_none_or(|owner| Scope::from_wire(owner) == scope)
59}
60
61fn region_of(step: Step<'_>, routes: &RouteGeometries, subject: EntityRef) -> Option<GridRect> {
63 match subject {
64 EntityRef::Block(id) => block_rect(step, id),
65 EntityRef::Pin(id) => pin_anchor(step, id).map(spot),
66 EntityRef::Route(id) => route_region(step, routes, id),
67 EntityRef::RouteLabel(id) => {
68 let owner = step.route_label(id)?.owner;
69 route_region(step, routes, owner)
70 }
71 EntityRef::Text(id) => text_pos(step, id).map(spot),
72 EntityRef::Area(id) => area_rect(step, id),
73 EntityRef::Image(id) => image_rect(step, id).map(grid_bounds),
74 EntityRef::Document | EntityRef::Asset(_) => None,
75 }
76}
77
78fn block_rect(step: Step<'_>, id: BlockId) -> Option<GridRect> {
79 Some(step.block(id)?.rect)
80}
81
82fn pin_anchor(step: Step<'_>, id: PinId) -> Option<GridPoint> {
86 let pin = step.pin(id)?;
87 let owner = pin.owner;
88 Some(slot_anchor(block_rect(step, owner), pin.slot, pin.rect))
89}
90
91fn slot_anchor(owner: Option<GridRect>, slot: PinSlot, body: GridRect) -> GridPoint {
92 let Some(rect) = owner else {
93 return center(body);
94 };
95 GridPoint {
96 x: match slot.side {
97 PinSide::West => rect.left(),
98 PinSide::East => rect.right(),
99 },
100 y: pin_slot_row(rect.top(), slot.offset),
101 }
102}
103
104fn route_region(step: Step<'_>, routes: &RouteGeometries, id: RouteId) -> Option<GridRect> {
109 if let Some(geometry) = routes.get(&id) {
110 return core::iter::once(geometry.start_pos)
111 .chain(geometry.iter_edges().map(|(_, edge)| edge.end))
112 .map(spot)
113 .reduce(union);
114 }
115 let route = step.route(id)?;
116 let ends = [route.from, route.to]
117 .into_iter()
118 .filter_map(|pin| pin_anchor(step, pin));
119 let through = route.waypoints.iter().map(|waypoint| waypoint.pos);
120 ends.chain(through).map(spot).reduce(union)
121}
122
123fn text_pos(step: Step<'_>, id: TextId) -> Option<GridPoint> {
124 Some(step.text(id)?.pos)
125}
126
127fn area_rect(step: Step<'_>, id: AreaId) -> Option<GridRect> {
128 Some(step.area(id)?.rect)
129}
130
131fn image_rect(step: Step<'_>, id: ImageId) -> Option<ScreenRect> {
132 Some(step.image(id)?.rect)
133}
134
135fn grid_bounds(rect: ScreenRect) -> GridRect {
138 let px = artwork_rect(rect);
139 let cells = |v: f32, up: bool| {
140 let scaled = v / GRID_SIZE;
141 (if up { scaled.ceil() } else { scaled.floor() }) as i32
142 };
143 let (left, top) = (cells(px.min.x, false), cells(px.min.y, false));
144 let (right, bottom) = (cells(px.max.x, true), cells(px.max.y, true));
145 GridRect {
146 top_left: GridPoint { x: left, y: top },
147 size: GridSize {
148 w: (right - left) as u32,
149 h: (bottom - top) as u32,
150 },
151 }
152}
153
154fn spot(at: GridPoint) -> GridRect {
155 GridRect {
156 top_left: at,
157 size: GridSize::default(),
158 }
159}
160
161fn center(rect: GridRect) -> GridPoint {
162 GridPoint {
163 x: rect.top_left.x + (rect.size.w / 2) as i32,
164 y: rect.top_left.y + (rect.size.h / 2) as i32,
165 }
166}
167
168fn union(a: GridRect, b: GridRect) -> GridRect {
169 GridRect::from_two_pos(
170 GridPoint {
171 x: a.left().min(b.left()),
172 y: a.top().min(b.top()),
173 },
174 GridPoint {
175 x: a.right().max(b.right()),
176 y: a.bottom().max(b.bottom()),
177 },
178 )
179}
180
181const HOLD: Duration = Duration::from_secs(1);
183const FADE: Duration = Duration::from_millis(500);
185pub const CLEAR: f32 = GRID_SIZE * 0.6;
188const CORNER: WorldPx = WorldPx::new(GRID_SIZE * 0.5);
189const RING_WIDTH: f32 = 2.0;
190
191#[derive(Clone, Copy)]
193struct Lit {
194 spotlight: Spotlight,
195 raised: Duration,
196}
197
198#[derive(Clone, Copy, Default)]
201pub struct Spotlighter {
202 lit: Option<Lit>,
203}
204
205impl Spotlighter {
206 pub fn light(&mut self, now: Duration, spotlight: Spotlight) {
209 self.lit = Some(Lit {
210 spotlight,
211 raised: now,
212 });
213 }
214
215 pub fn ring<C: Canvas>(&mut self, scope: Scope, style: &mut Style<'_, C>) {
220 let Some(lit) = self.lit else {
221 return;
222 };
223 let elapsed = style.now().saturating_sub(lit.raised);
224 let Some(opacity) = strength(elapsed) else {
225 self.lit = None;
226 return;
227 };
228 match HOLD.checked_sub(elapsed) {
231 Some(left) => style.request_repaint_after(left),
232 None => style.request_repaint(),
233 }
234 if lit.spotlight.scope != scope {
235 return;
236 }
237 let region = px_rect(lit.spotlight.region).expand(CLEAR);
238 style.with_opacity(opacity, |style| {
239 style.rect(
240 region,
241 CORNER,
242 Role::Transparent,
243 (RING_WIDTH, Role::ChangeRing),
244 );
245 });
246 }
247}
248
249fn strength(elapsed: Duration) -> Option<f32> {
251 let Some(fading) = elapsed.checked_sub(HOLD) else {
252 return Some(1.0);
253 };
254 if fading >= FADE {
255 return None;
256 }
257 Some(1.0 - fading.as_secs_f32() / FADE.as_secs_f32())
258}
259
260#[cfg(test)]
261mod tests;