Skip to main content

blockworx/
grid.rs

1//! The grid's pixel policy: the pitch, the snapping ladder, and the
2//! screen-mapping constants every shape is measured against. Purely
3//! `f32`/`i32` arithmetic — the grid↔geometry conversions live once in
4//! [`crate::edit::lower`], over the document's own geometry types.
5
6use egui::{Pos2, Rect, Vec2, pos2};
7
8use crate::units::WorldPx;
9
10pub const GRID_SIZE: f32 = 15.0;
11
12/// Maximum length of a short on-canvas label (pin name and its two lines, block
13/// or area title, pin/block tag, route label). The rename editors enforce
14/// this so an accidental paste of a long string can't break the canvas layout.
15pub const MAX_LABEL_CHARS: usize = 32;
16/// Maximum length of a pin's short "location" designator (its `tag`, e.g. "A1").
17/// Tighter than [`MAX_LABEL_CHARS`] since it's a compact label drawn above the stub.
18pub const MAX_LOCATION_CHARS: usize = 8;
19/// Maximum length of a free-floating text box. Much larger than a label since a
20/// text box is a paragraph annotation, but still bounded so an accidental paste
21/// of a huge string can't blow up the layout.
22pub const MAX_TEXT_BOX_CHARS: usize = 2000;
23pub const SHIM: f32 = GRID_SIZE * 0.7;
24/// Padding between a multi-selection's contents and the group frame drawn around
25/// them, so the frame sits clear of the shapes' own edges rather than hugging them.
26pub const GROUP_SELECTION_PAD: f32 = GRID_SIZE * 1.5;
27pub const RESIZE_SHIM: f32 = GRID_SIZE / 4.0;
28pub const MOVE_HOVER_DISTANCE: f32 = GRID_SIZE * 0.8;
29pub const PORT_RADIUS: WorldPx = WorldPx::new(GRID_SIZE * 0.3);
30/// Larger radius used for hit-testing interactive control points (ports,
31/// waypoints, resize handles). Intentionally bigger than `PORT_RADIUS` so
32/// targets are easier to click without changing how they look.
33pub const HIT_RADIUS: WorldPx = WorldPx::new(GRID_SIZE * 0.6);
34/// Generous margin for snapping a route to a pin: the cursor registers anywhere
35/// near the pin's stub or its end, not just on the connection point. Kept just
36/// under half a [`PIN_PITCH`] so adjacent pins stay distinct.
37pub const ROUTE_HIT_MARGIN: WorldPx = WorldPx::new(GRID_SIZE * 0.9);
38pub const LINE_RADIUS: f32 = GRID_SIZE * 0.7;
39pub const TITLE_TEXT_SIZE: f32 = GRID_SIZE * 1.0;
40/// A block's type label, drawn slightly smaller than its title.
41pub const BLOCK_TYPE_TEXT_SIZE: f32 = GRID_SIZE * 0.85;
42pub const PORT_TEXT_SIZE: f32 = GRID_SIZE * 0.9;
43pub const ROUTE_TEXT_SIZE: f32 = GRID_SIZE * 0.8;
44pub const TAG_TEXT_SIZE: f32 = GRID_SIZE * 0.75;
45/// Subtitle (second line) of a pin/port name. Slightly smaller than the name.
46pub const PORT_SUBTITLE_TEXT_SIZE: f32 = GRID_SIZE * 0.6;
47/// Horizontal gap between a block/port edge and the start of its tag label.
48pub const TAG_SHIM: f32 = GRID_SIZE * 0.25;
49/// Vertical nudge applied to the pin's type label, which is otherwise centered
50/// one [`GRID_SIZE`] below the name. Zero keeps it centered exactly a grid cell
51/// below the stub; the wider pin pitch leaves room for the full gap.
52pub const TYPE_SHIM_Y: f32 = 0.0;
53pub const PORT_RENDER_HEIGHT: f32 = GRID_SIZE * 2.2;
54
55/// Side length of the passive "locked" padlock hint drawn in a selected locked
56/// block's upper-right corner. Grid-relative and small so it reads as a hint.
57pub const LOCK_HINT_SIZE: f32 = GRID_SIZE * 0.8;
58
59/// Stroke width of a block/port outline, in world units. The single source of
60/// truth shared by the frame (`draw_block_frame`) and the pins that butt against
61/// it: a pin stub stops half this width outside the bbox edge so it meets the
62/// outline's outer face instead of crossing into it.
63pub const BLOCK_STROKE_WIDTH: f32 = 1.0;
64
65/// Vertical space a pin slot occupies, in grid units. Adjacent integer offsets
66/// are this many grid units apart.
67pub const PIN_PITCH_GRID: i32 = 3;
68
69/// On-screen distance between consecutive pin slots.
70pub const PIN_PITCH: f32 = GRID_SIZE * PIN_PITCH_GRID as f32;
71
72/// Vertical margin from a block's top edge to its first pin slot, and the
73/// matching clearance kept below the lowest slot. A whole number of grid cells,
74/// kept independent of [`PIN_PITCH`] so the pitch may be odd: the first pin sits
75/// this far down and pins are a full [`PIN_PITCH`] apart.
76pub const PIN_TOP_MARGIN: f32 = GRID_SIZE * 2.0;
77
78/// When a block is expanded into its own view, its boundary ports are laid out
79/// to mirror the parent's pin arrangement, magnified by this factor so there is
80/// room to place interior child blocks between them.
81pub const DEFAULT_SCALE_FOR_NEW_VIEW: i32 = 4;
82
83/// A grid cell in the `x,y` spelling scripts use: the cell nearest a world
84/// position.
85#[derive(Clone, Copy, PartialEq, Eq, Debug)]
86pub struct GridCell {
87    pub x: i32,
88    pub y: i32,
89}
90
91impl GridCell {
92    pub fn at(world: Pos2) -> Self {
93        Self {
94            x: (world.x / GRID_SIZE).round() as i32,
95            y: (world.y / GRID_SIZE).round() as i32,
96        }
97    }
98}
99
100impl std::fmt::Display for GridCell {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        write!(f, "{},{}", self.x, self.y)
103    }
104}
105
106pub fn snap_to_grid(pos: Pos2) -> Pos2 {
107    Pos2::new(round_to_grid(pos.x), round_to_grid(pos.y))
108}
109
110/// Grid-snap a translation `delta` (the vector form of [`snap_to_grid`]), used to
111/// round a drag offset to whole grid steps.
112pub fn snap_offset(delta: Vec2) -> Vec2 {
113    snap_to_grid(delta.to_pos2()).to_vec2()
114}
115
116pub fn round_to_grid(value: f32) -> f32 {
117    (value / GRID_SIZE).round() * GRID_SIZE
118}
119
120/// Screen y of pin slot `offset` within a shape whose top edge is at `top`.
121/// `offset` is a plain contiguous integer index; the pitch lives here, not in
122/// the stored value. The first slot sits a [`PIN_TOP_MARGIN`] below the top edge;
123/// each further slot is one [`PIN_PITCH`] down.
124pub fn pin_offset_y(top: f32, offset: u32) -> f32 {
125    top + PIN_TOP_MARGIN + PIN_PITCH * offset as f32
126}
127
128/// [`pin_offset_y`] in whole grid cells: the row slot `offset` sits on in a
129/// shape whose top edge is at cell `top`. The document's own geometry is
130/// integer, so a reader that stays in grid space never crosses through
131/// pixels to place a pin.
132pub fn pin_slot_row(top: i32, offset: u32) -> i32 {
133    top + grid_i32(pin_offset_y(0.0, offset))
134}
135
136/// Round a pixel offset to the nearest pin slot, in pixels.
137pub fn round_to_pitch(value: f32) -> f32 {
138    (value / PIN_PITCH).round() * PIN_PITCH
139}
140
141/// Pixel offset (`slot * PIN_PITCH`) → slot index.
142pub fn pin_slot(px: f32) -> u32 {
143    (px / PIN_PITCH).round().max(0.0) as u32
144}
145
146/// The largest pin slot that fits within a shape of the given `height` while
147/// keeping at least a [`PIN_TOP_MARGIN`] of clearance below it. Slot `s` sits at
148/// [`pin_offset_y`] = `top + PIN_TOP_MARGIN + PIN_PITCH * s`, so the lowest slot
149/// leaving a `PIN_TOP_MARGIN` above the bottom edge is `floor((height - 2 *
150/// PIN_TOP_MARGIN) / PIN_PITCH)`. This is the bound that bounds-checks pin
151/// placement (see `Block::new_pin_locations`).
152pub fn max_pin_slot(height: f32) -> u32 {
153    (((height - 2.0 * PIN_TOP_MARGIN) / PIN_PITCH).floor() as i32).max(0) as u32
154}
155
156/// A block's height is constrained to `2 * PIN_TOP_MARGIN + h * PIN_PITCH` for an
157/// unsigned `h`: a [`PIN_TOP_MARGIN`] above the top pin slot and below the bottom
158/// one, plus one [`PIN_PITCH`] per added slot. So a height-`h` block holds `h + 1`
159/// pins, at offsets `0..=h` (and `max_pin_slot` of this height is exactly `h`).
160/// This rounds a raw pixel height to the nearest such valid height, floored at the
161/// `h = 0` minimum of `2 * PIN_TOP_MARGIN`.
162pub fn snap_block_height(height_px: f32) -> f32 {
163    let slots = ((height_px - 2.0 * PIN_TOP_MARGIN) / PIN_PITCH)
164        .round()
165        .max(0.0);
166    2.0 * PIN_TOP_MARGIN + slots * PIN_PITCH
167}
168
169/// [`snap_block_height`] expressed in whole grid cells — the form a block's
170/// `inner.size.h` stores. The minimum is 4 cells (`2 * PIN_TOP_MARGIN`) and each
171/// added slot adds [`PIN_PITCH_GRID`] cells: one of `4, 7, 10, …`.
172pub fn snap_block_height_cells(cells: u32) -> u32 {
173    (snap_block_height(cells as f32 * GRID_SIZE) / GRID_SIZE).round() as u32
174}
175
176/// The smallest whole grid width that still holds `width_px`. A resize floor has
177/// to be expressed on the lattice the drop snaps to, or the snap rounds back
178/// through it.
179pub fn ceil_to_grid(width_px: f32) -> f32 {
180    (width_px / GRID_SIZE).ceil().max(0.0) * GRID_SIZE
181}
182
183/// The smallest valid block height (see [`snap_block_height`]) that still holds
184/// `height_px` — the height counterpart of [`ceil_to_grid`].
185pub fn ceil_block_height(height_px: f32) -> f32 {
186    let slots = ((height_px - 2.0 * PIN_TOP_MARGIN) / PIN_PITCH)
187        .ceil()
188        .max(0.0);
189    2.0 * PIN_TOP_MARGIN + slots * PIN_PITCH
190}
191
192pub fn grid_rect(rect: Rect) -> Rect {
193    Rect::from_min_max(
194        snap_to_grid(pos2(rect.min.x, rect.min.y)),
195        snap_to_grid(pos2(rect.max.x, rect.max.y)),
196    )
197}
198
199// --- Cell ↔ pixel scalars -----------------------------------------------------
200//
201// The scalar half of the mapping: one axis, no geometry type involved. The
202// typed conversions that build on them ([`crate::edit::lower::px_rect`] and
203// friends) are the document's, and live there.
204
205pub fn px(grid: i32) -> f32 {
206    grid as f32 * GRID_SIZE
207}
208
209/// The world-space position of a grid cell's corner, for the places that
210/// author canvas coordinates in whole cells.
211pub fn grid_pos(x: i32, y: i32) -> Pos2 {
212    pos2(px(x), px(y))
213}
214
215pub fn px_u(grid: u32) -> f32 {
216    grid as f32 * GRID_SIZE
217}
218
219pub fn grid_i32(px: f32) -> i32 {
220    (px / GRID_SIZE).round() as i32
221}
222
223pub fn grid_u32(px: f32) -> u32 {
224    (px / GRID_SIZE).round().max(0.0) as u32
225}
226
227/// A world-space extent in whole grid cells, rounded *up* so the cell count
228/// always fully encloses the extent, and clamped to at least one cell per
229/// axis.
230pub fn grid_u32_ceil(px: f32) -> u32 {
231    (px / GRID_SIZE).ceil().max(1.0) as u32
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn round_to_pitch_snaps_to_nearest_slot() {
240        assert_eq!(round_to_pitch(0.0), 0.0);
241        assert_eq!(round_to_pitch(PIN_PITCH * 0.4), 0.0); // < half a pitch
242        assert_eq!(round_to_pitch(PIN_PITCH * 0.6), PIN_PITCH); // > half a pitch
243        assert_eq!(round_to_pitch(PIN_PITCH * 3.0 + 2.0), PIN_PITCH * 3.0);
244    }
245
246    #[test]
247    fn pin_slot_inverts_the_pitch_mapping() {
248        assert_eq!(pin_slot(0.0), 0);
249        assert_eq!(pin_slot(PIN_PITCH), 1);
250        assert_eq!(pin_slot(PIN_PITCH * 4.0), 4);
251        assert_eq!(pin_slot(-5.0), 0); // clamped
252    }
253
254    #[test]
255    fn max_pin_slot_reserves_a_top_margin_below_the_lowest_slot() {
256        // floor((height - 2 * PIN_TOP_MARGIN) / PIN_PITCH).
257        let h = |slots: f32| 2.0 * PIN_TOP_MARGIN + slots * PIN_PITCH;
258        assert_eq!(max_pin_slot(h(9.0)), 9); // floor(9.0)
259        assert_eq!(max_pin_slot(h(1.5)), 1); // floor(1.5)
260        assert_eq!(max_pin_slot(h(0.0)), 0); // floor(0)
261        assert_eq!(max_pin_slot(h(-0.5)), 0); // clamped, never negative
262
263        // The lowest offered slot keeps at least a PIN_TOP_MARGIN above the bottom.
264        let height = h(9.0);
265        let lowest_pin_y = pin_offset_y(0.0, max_pin_slot(height));
266        assert!(height - lowest_pin_y >= PIN_TOP_MARGIN);
267    }
268
269    #[test]
270    fn snap_block_height_rounds_to_a_valid_block_height() {
271        // Valid heights are 2 * PIN_TOP_MARGIN + h * PIN_PITCH (h >= 0).
272        let h = |slots: f32| 2.0 * PIN_TOP_MARGIN + slots * PIN_PITCH;
273        assert_eq!(snap_block_height(h(0.0)), h(0.0)); // h = 0, the floor
274        assert_eq!(snap_block_height(h(0.4)), h(0.0)); // rounds down
275        assert_eq!(snap_block_height(h(0.6)), h(1.0)); // rounds up
276        assert_eq!(snap_block_height(0.0), h(0.0)); // floored at the minimum
277        assert_eq!(snap_block_height_cells(1), 4); // 4 grid cells minimum
278        assert_eq!(snap_block_height_cells(5), 4); // nearest valid (75px -> 60px)
279        assert_eq!(snap_block_height_cells(19), 19); // already valid (285px)
280        // The ceilings never round *through* their input, unlike the snaps above.
281        assert_eq!(ceil_to_grid(GRID_SIZE * 2.0), GRID_SIZE * 2.0);
282        assert_eq!(ceil_to_grid(GRID_SIZE * 2.0 + 0.1), GRID_SIZE * 3.0);
283        assert_eq!(ceil_block_height(h(1.0)), h(1.0));
284        assert_eq!(ceil_block_height(h(1.0) + 0.1), h(2.0));
285        assert_eq!(ceil_block_height(0.0), h(0.0));
286        // A top block is born at a height the resize snap would keep as-is.
287        assert_eq!(
288            snap_block_height_cells(crate::edit::create::TOP_BLOCK_DEFAULT_HEIGHT),
289            crate::edit::create::TOP_BLOCK_DEFAULT_HEIGHT
290        );
291    }
292
293    #[test]
294    fn a_height_h_block_holds_h_plus_one_pins() {
295        // height 2 * PIN_TOP_MARGIN + h * PIN_PITCH => max_pin_slot == h => pins 0..=h.
296        for h in 0..6u32 {
297            let height = 2.0 * PIN_TOP_MARGIN + h as f32 * PIN_PITCH;
298            assert_eq!(
299                snap_block_height(height),
300                height,
301                "h={h} not a valid height"
302            );
303            assert_eq!(max_pin_slot(height), h, "h={h} wrong capacity");
304        }
305    }
306
307    #[test]
308    fn pin_offset_y_uses_the_pitch() {
309        // top + PIN_TOP_MARGIN + PIN_PITCH * offset: a leading top margin.
310        assert_eq!(pin_offset_y(0.0, 0), PIN_TOP_MARGIN);
311        assert_eq!(pin_offset_y(0.0, 1), PIN_TOP_MARGIN + PIN_PITCH);
312        assert_eq!(
313            pin_offset_y(100.0, 2),
314            100.0 + PIN_TOP_MARGIN + 2.0 * PIN_PITCH
315        );
316    }
317
318    #[test]
319    fn the_slot_row_is_the_pixel_offset_in_cells() {
320        assert_eq!(pin_slot_row(0, 0), grid_i32(PIN_TOP_MARGIN));
321        assert_eq!(pin_slot_row(0, 1), grid_i32(PIN_TOP_MARGIN + PIN_PITCH));
322        assert_eq!(pin_slot_row(7, 2), 7 + grid_i32(pin_offset_y(0.0, 2)));
323    }
324}