Skip to main content

blockworx_tools/
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::Canvas;
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 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: Duration,
196}
197
198/// Whatever ring is currently up. One per session, owned by the app: a ring is
199/// state the canvas draws from, not something the canvas remembers for us.
200#[derive(Clone, Copy, Default)]
201pub struct Spotlighter {
202    lit: Option<Lit>,
203}
204
205impl Spotlighter {
206    /// Ring what a step changed. Whatever was lit gives way — one change is
207    /// being pointed at, and the newest one is it.
208    pub fn light(&mut self, now: Duration, spotlight: Spotlight) {
209        self.lit = Some(Lit {
210            spotlight,
211            raised: now,
212        });
213    }
214
215    /// Draw the ring wherever its fade has got to, in `scope` only: a ring lit
216    /// one level down frames nothing a viewer up here can see. Called every
217    /// canvas frame; a frame with nothing lit draws nothing and asks for
218    /// nothing.
219    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        // Standing still costs nothing until it is time to go, which is what
229        // lets an idle frame with a ring up still settle.
230        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
249/// How strongly the ring shows at `elapsed`, or `None` once it is gone.
250fn 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;