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::Renderer;
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(crate) 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: f64,
196}
197
198fn id() -> egui::Id {
199 egui::Id::new("spotlight")
200}
201
202pub fn light(ctx: &egui::Context, spotlight: Spotlight) {
205 let raised = ctx.input(|i| i.time);
206 ctx.data_mut(|data| data.insert_temp(id(), Lit { spotlight, raised }));
207 ctx.request_repaint();
208}
209
210pub fn ring<R: Renderer>(ctx: &egui::Context, scope: Scope, style: &mut Style<'_, R>) {
214 let Some(lit) = ctx.data(|data| data.get_temp::<Lit>(id())) else {
215 return;
216 };
217 let elapsed = Duration::from_secs_f64((ctx.input(|i| i.time) - lit.raised).max(0.0));
218 let Some(opacity) = strength(elapsed) else {
219 ctx.data_mut(|data| data.remove::<Lit>(id()));
220 return;
221 };
222 match HOLD.checked_sub(elapsed) {
225 Some(left) => ctx.request_repaint_after(left),
226 None => ctx.request_repaint(),
227 }
228 if lit.spotlight.scope != scope {
229 return;
230 }
231 let region = px_rect(lit.spotlight.region).expand(CLEAR);
232 style.with_opacity(opacity, |style| {
233 style.rect(
234 region,
235 CORNER,
236 Role::Transparent,
237 (RING_WIDTH, Role::ChangeRing),
238 );
239 });
240}
241
242fn strength(elapsed: Duration) -> Option<f32> {
244 let Some(fading) = elapsed.checked_sub(HOLD) else {
245 return Some(1.0);
246 };
247 if fading >= FADE {
248 return None;
249 }
250 Some(1.0 - fading.as_secs_f32() / FADE.as_secs_f32())
251}
252
253#[cfg(test)]
254mod tests;