Skip to main content

blockworx/
spotlight.rs

1//! Where a document step worked: the scope it happened in and the grid
2//! region that contains its subjects.
3//!
4//! One derivation of "what did this change touch", consumed twice — the
5//! camera aims at it and the canvas rings it — so the two cannot disagree
6//! about where a change was. The *names* come from the manifest row the act
7//! wrote ([`blockworx_store::worked`]); the region is measured at read time
8//! from the documents either side of the step.
9
10use 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/// Where a commit worked. Advisory: a camera hint and a ring, never a claim
26/// about what the commit *did*.
27#[derive(Clone, Copy, PartialEq, Eq, Debug)]
28pub struct Spotlight {
29    pub scope: Scope,
30    pub region: GridRect,
31}
32
33/// The scope and region an act worked in, read across the step it made.
34///
35/// A region is measured in the scope it frames — a scope is a coordinate
36/// space of its own — so a subject that stands in another one is left out
37/// of the union rather than stretching it across two spaces. Subjects with
38/// no scope at all (the document itself, an asset) stay in: they happened
39/// wherever the rest of the act did.
40///
41/// `routes` supplies the solved polyline for a wire the act names. A wire
42/// the router has not solved falls back to its endpoints and authored
43/// waypoints, which is the corridor it was drawn through if not the exact
44/// path taken.
45pub 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
56/// Whether a subject stands in `scope`, or in no scope at all.
57fn 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
61/// The grid region one subject stands in.
62fn 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
82/// Where a pin shows: the slot anchor on its owner's boundary, which is where
83/// the user sees the pin at all. Its port body is interior geometry, one scope
84/// in, and is not what a viewer looking at the parent needs framed.
85fn 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
104/// A wire's corridor: the polyline the router solved for it, or — where none
105/// is solved — its two endpoints and whatever waypoints it was authored
106/// through. The fallback frames the corridor the wire was drawn through
107/// rather than the exact path taken, which is what a camera and a ring need.
108fn 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
135/// Artwork is the document's one unsnapped rect; the region that frames it is
136/// the whole cells it covers.
137fn 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
181/// How long the ring stands at full strength before it goes.
182const HOLD: Duration = Duration::from_secs(1);
183/// And how long it takes to go.
184const FADE: Duration = Duration::from_millis(500);
185/// The air between what changed and the ring round it, so the ring reads as
186/// pointing at the thing rather than as part of it.
187pub(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/// The ring, while one is up: what it frames and when it was raised.
192#[derive(Clone, Copy)]
193struct Lit {
194    spotlight: Spotlight,
195    raised: f64,
196}
197
198fn id() -> egui::Id {
199    egui::Id::new("spotlight")
200}
201
202/// Ring what a step changed. Whatever was lit gives way — one change is being
203/// pointed at, and the newest one is it.
204pub 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
210/// Draw the ring wherever its fade has got to, in `scope` only: a ring lit one
211/// level down frames nothing a viewer up here can see. Called every canvas
212/// frame; a frame with nothing lit draws nothing and asks for nothing.
213pub 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    // Standing still costs nothing until it is time to go, which is what lets
223    // an idle frame with a ring up still settle.
224    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
242/// How strongly the ring shows at `elapsed`, or `None` once it is gone.
243fn 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;