Skip to main content

blockworx/render/
bounds.rs

1//! Coarse, Painter-free world-space bounds for broad-phase spatial queries.
2//!
3//! [`Bounded::bounds`] returns a box enclosing an object's whole painted and
4//! hittable footprint — including stub and label overhang — computed from pure
5//! geometry plus a character-count text-width estimate. It never lays out a
6//! galley, so it is cheap enough to compute for every object each time the
7//! [`SpatialIndex`](crate::widget::spatial::SpatialIndex) is rebuilt. The shared
8//! index (viewport culling + hit-testing) queries these bounds; the exact
9//! fine-phase tests then decide the actual hit, so `bounds` only has to *not
10//! under-cover* — a slightly generous box costs a few extra candidates, an
11//! under-sized one would drop a real hit.
12
13use crate::grid::{BLOCK_TYPE_TEXT_SIZE, GRID_SIZE, ROUTE_TEXT_SIZE, TITLE_TEXT_SIZE};
14use crate::presentation::RouteGeometry;
15use crate::shape::ShapeRef;
16use crate::widget::auto_route::Wire;
17use blockworx_geom::{Rect, vec2};
18
19/// How far a shape's footprint can extend past its `gui_rect` from fixed-size
20/// decorations: a pin stub reaches one `GRID_SIZE` out, and the widest point-snap
21/// margin (`ROUTE_HIT_MARGIN`, ~0.9 grid) plus the tag rise sit within two cells.
22/// Query rects are expanded by this before testing, and every indexed bound
23/// already includes it.
24pub const QUERY_MARGIN: f32 = GRID_SIZE * 2.0;
25
26/// Conservative upper bound on a proportional glyph's advance as a fraction of
27/// the font size — lets a label's width be estimated from its character count
28/// without a galley. Biased high so the estimate never under-covers a real label.
29const CHAR_ADVANCE: f32 = 0.75;
30
31fn estimate_text_width(text: &str, size: f32) -> f32 {
32    text.chars().count() as f32 * size * CHAR_ADVANCE
33}
34
35/// A world-space bounding box enclosing an object's whole painted/hittable
36/// footprint, for broad-phase spatial queries. See the module docs.
37pub trait Bounded {
38    fn bounds(&self) -> Rect;
39}
40
41impl Bounded for ShapeRef<'_> {
42    fn bounds(&self) -> Rect {
43        shape_bounds_at(self, self.gui_rect())
44    }
45}
46
47/// [`Bounded::bounds`] for a shape sitting at a hypothetical `rect` — where a
48/// drag or resize is previewing it — so a supposed footprint is measured by the
49/// same rule as a settled one.
50pub fn shape_bounds_at(shape: &ShapeRef<'_>, rect: Rect) -> Rect {
51    let mut r = rect;
52    // The title is centered above/below the rect: a long one overhangs left
53    // and right, and it always adds a line of height. (Pin names anchor
54    // inward, so they are already inside `gui_rect`.)
55    if let Some(title) = shape.title().filter(|t| !t.hidden) {
56        let over_x =
57            ((estimate_text_width(title.name, TITLE_TEXT_SIZE) - r.width()) / 2.0).max(0.0);
58        r = r.expand2(vec2(over_x, TITLE_TEXT_SIZE));
59    }
60    // The type label is left-anchored at the top edge and grows rightward and
61    // up, so a long one overhangs the right edge.
62    if let Some(type_label) = shape
63        .type_label()
64        .filter(|t| !t.hidden && !t.name.is_empty())
65    {
66        let over_x =
67            (estimate_text_width(type_label.name, BLOCK_TYPE_TEXT_SIZE) - r.width()).max(0.0);
68        r.max.x += over_x;
69        r.min.y -= BLOCK_TYPE_TEXT_SIZE;
70    }
71    r.expand(QUERY_MARGIN)
72}
73
74/// [`Bounded`] for a route: its painted footprint needs the solved geometry,
75/// which lives beside the document, so it can't be a method on either half
76/// alone.
77pub fn route_bounds(wire: &Wire<'_>, geometry: &RouteGeometry) -> Rect {
78    let mut r = Rect::from_points(&geometry.points());
79    // A route label is centered on a point of the wire and extends by half its
80    // width horizontally and a line vertically, so widen by that when present.
81    if !wire.labels.is_empty() {
82        let half_w = estimate_text_width(&wire.route.name, ROUTE_TEXT_SIZE) / 2.0;
83        r = r.expand2(vec2(half_w, ROUTE_TEXT_SIZE));
84    }
85    r.expand(QUERY_MARGIN)
86}