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