Skip to main content

r/interpreter/grid/
units.rs

1//! Grid unit system — flexible measurements for grid graphics.
2//!
3//! R's grid package uses a rich unit system where lengths can be expressed in
4//! physical units (cm, inches, points), relative units (npc, snpc), data
5//! coordinates (native), or text-metric units (lines, char, strwidth).
6//! Units can be combined with arithmetic (`+`, `-`, `*`) to create compound
7//! measurements that are resolved at drawing time.
8
9use std::ops::{Add, Sub};
10
11// region: UnitType
12
13/// The type of measurement for a grid unit value.
14#[derive(Clone, Debug, PartialEq)]
15pub enum UnitType {
16    /// Normalized parent coordinates (0-1 maps to parent viewport extent).
17    Npc,
18    /// Centimeters.
19    Cm,
20    /// Inches.
21    Inches,
22    /// Millimeters.
23    Mm,
24    /// Points (1/72 inch, typographic).
25    Points,
26    /// Text line heights (fontsize * lineheight).
27    Lines,
28    /// Character widths (approximate, based on fontsize).
29    Char,
30    /// Data coordinates (mapped through viewport xscale/yscale).
31    Native,
32    /// Flexible/proportional space (resolved by layout engine, returns 0 before layout).
33    Null,
34    /// Square normalized parent coordinates (uses the smaller of width/height).
35    Snpc,
36    /// Width of the given string in the current font.
37    StrWidth(String),
38    /// Height of the given string in the current font.
39    StrHeight(String),
40    /// Width of a named grob.
41    GrobWidth(String),
42    /// Height of a named grob.
43    GrobHeight(String),
44}
45
46impl UnitType {
47    /// Whether this unit type is absolute (resolution does not depend on viewport size).
48    pub fn is_absolute(&self) -> bool {
49        matches!(
50            self,
51            UnitType::Cm | UnitType::Inches | UnitType::Mm | UnitType::Points
52        )
53    }
54}
55
56// endregion
57
58// region: UnitContext
59
60/// Context needed to resolve relative and data-dependent units to centimeters.
61///
62/// This captures the current viewport dimensions and font metrics at
63/// drawing time so that relative units (npc, native, lines, etc.) can be
64/// converted to physical measurements.
65#[derive(Clone, Debug)]
66pub struct UnitContext {
67    /// Viewport width in centimeters.
68    pub viewport_width_cm: f64,
69    /// Viewport height in centimeters.
70    pub viewport_height_cm: f64,
71    /// Data coordinate range on the x-axis: (min, max).
72    pub xscale: (f64, f64),
73    /// Data coordinate range on the y-axis: (min, max).
74    pub yscale: (f64, f64),
75    /// Font size in points.
76    pub fontsize_pt: f64,
77    /// Line height multiplier (e.g. 1.2).
78    pub lineheight: f64,
79}
80
81impl Default for UnitContext {
82    fn default() -> Self {
83        UnitContext {
84            viewport_width_cm: 17.78,  // ~7 inches (default R device width)
85            viewport_height_cm: 17.78, // ~7 inches
86            xscale: (0.0, 1.0),
87            yscale: (0.0, 1.0),
88            fontsize_pt: 12.0,
89            lineheight: 1.2,
90        }
91    }
92}
93
94impl UnitContext {
95    /// Resolve a unit's i-th element along the x axis to cm.
96    pub fn resolve_x(&self, unit: &Unit, i: usize) -> f64 {
97        let idx = i % unit.values.len();
98        resolve_one(unit.values[idx], &unit.units[idx], self, Axis::X)
99    }
100
101    /// Resolve a unit's i-th element along the y axis to cm.
102    pub fn resolve_y(&self, unit: &Unit, i: usize) -> f64 {
103        let idx = i % unit.values.len();
104        resolve_one(unit.values[idx], &unit.units[idx], self, Axis::Y)
105    }
106
107    /// Resolve a unit's i-th element as a size (geometric mean of x/y) to cm.
108    pub fn resolve_size(&self, unit: &Unit, i: usize) -> f64 {
109        let idx = i % unit.values.len();
110        let x = resolve_one(unit.values[idx], &unit.units[idx], self, Axis::X);
111        let y = resolve_one(unit.values[idx], &unit.units[idx], self, Axis::Y);
112        (x.abs() * y.abs()).sqrt()
113    }
114}
115
116// endregion
117
118// region: Axis
119
120/// Which axis a unit is being resolved along. Matters for relative units
121/// like `Npc` (which uses width for x, height for y) and `Native`.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub enum Axis {
124    X,
125    Y,
126}
127
128// endregion
129
130// region: Unit
131
132/// A grid unit — one or more (value, type) pairs representing a measurement.
133///
134/// Like R's `unit()`, a `Unit` can contain multiple values with different
135/// types. Arithmetic on units produces compound units: `unit(1, "cm") + unit(2, "mm")`
136/// becomes a single `Unit` with two entries that are summed at resolution time.
137#[derive(Clone, Debug)]
138pub struct Unit {
139    /// The numeric values.
140    pub values: Vec<f64>,
141    /// The unit type for each value (parallel to `values`).
142    pub units: Vec<UnitType>,
143}
144
145impl Unit {
146    /// Create a unit with a single value and type.
147    pub fn new(value: f64, unit_type: UnitType) -> Self {
148        Unit {
149            values: vec![value],
150            units: vec![unit_type],
151        }
152    }
153
154    /// Shorthand: normalized parent coordinates.
155    pub fn npc(value: f64) -> Self {
156        Unit::new(value, UnitType::Npc)
157    }
158
159    /// Shorthand: centimeters.
160    pub fn cm(value: f64) -> Self {
161        Unit::new(value, UnitType::Cm)
162    }
163
164    /// Shorthand: inches.
165    pub fn inches(value: f64) -> Self {
166        Unit::new(value, UnitType::Inches)
167    }
168
169    /// Shorthand: millimeters.
170    pub fn mm(value: f64) -> Self {
171        Unit::new(value, UnitType::Mm)
172    }
173
174    /// Shorthand: points (1/72 inch).
175    pub fn points(value: f64) -> Self {
176        Unit::new(value, UnitType::Points)
177    }
178
179    /// Shorthand: text line heights.
180    pub fn lines(value: f64) -> Self {
181        Unit::new(value, UnitType::Lines)
182    }
183
184    /// Shorthand: null (flexible/proportional) unit.
185    pub fn null(value: f64) -> Self {
186        Unit::new(value, UnitType::Null)
187    }
188
189    /// Get the first scalar value (for simple single-value units like `unit(7, "cm")`).
190    pub fn value(&self) -> f64 {
191        *self.values.first().unwrap_or(&0.0)
192    }
193
194    /// Number of (value, type) pairs in this unit.
195    pub fn len(&self) -> usize {
196        self.values.len()
197    }
198
199    /// Whether this unit contains no values.
200    pub fn is_empty(&self) -> bool {
201        self.values.is_empty()
202    }
203
204    /// Whether all component unit types are absolute (no viewport/data dependency).
205    pub fn is_absolute(&self) -> bool {
206        self.units.iter().all(UnitType::is_absolute)
207    }
208
209    /// Resolve all values to centimeters along the given axis.
210    ///
211    /// For a compound unit (created by `+` or `-`), the result is a single
212    /// value: the sum of all component values converted to cm.
213    /// For a simple unit, returns a one-element vector.
214    pub fn to_cm(&self, ctx: &UnitContext, axis: Axis) -> Vec<f64> {
215        if self.values.is_empty() {
216            return vec![];
217        }
218
219        // Compound units (from Add/Sub) are summed into a single resolved value.
220        // Simple units return one value per entry.
221        let resolved: Vec<f64> = self
222            .values
223            .iter()
224            .zip(self.units.iter())
225            .map(|(&val, unit_type)| resolve_one(val, unit_type, ctx, axis))
226            .collect();
227
228        resolved
229    }
230
231    /// Resolve to a single cm value by summing all components.
232    ///
233    /// This is the most common usage: a compound unit like `unit(1, "cm") + unit(5, "mm")`
234    /// resolves to `1.5` cm.
235    pub fn to_cm_scalar(&self, ctx: &UnitContext, axis: Axis) -> f64 {
236        self.values
237            .iter()
238            .zip(self.units.iter())
239            .map(|(&val, unit_type)| resolve_one(val, unit_type, ctx, axis))
240            .sum()
241    }
242
243    /// Scalar multiply: scale all values by a factor.
244    pub fn scale(mut self, factor: f64) -> Self {
245        for v in &mut self.values {
246            *v *= factor;
247        }
248        self
249    }
250}
251
252/// Resolve a single (value, unit_type) pair to centimeters.
253fn resolve_one(value: f64, unit_type: &UnitType, ctx: &UnitContext, axis: Axis) -> f64 {
254    let viewport_cm = match axis {
255        Axis::X => ctx.viewport_width_cm,
256        Axis::Y => ctx.viewport_height_cm,
257    };
258
259    match unit_type {
260        UnitType::Npc => value * viewport_cm,
261        UnitType::Cm => value,
262        UnitType::Inches => value * 2.54,
263        UnitType::Mm => value / 10.0,
264        UnitType::Points => value / 72.0 * 2.54,
265        UnitType::Lines => {
266            // One line = fontsize_pt * lineheight, converted from points to cm
267            value * ctx.fontsize_pt * ctx.lineheight / 72.0 * 2.54
268        }
269        UnitType::Char => {
270            // Approximate character width as 0.6 * fontsize in points, to cm
271            value * ctx.fontsize_pt * 0.6 / 72.0 * 2.54
272        }
273        UnitType::Native => {
274            // Map data coordinate to npc, then to cm
275            let (scale_min, scale_max) = match axis {
276                Axis::X => ctx.xscale,
277                Axis::Y => ctx.yscale,
278            };
279            let range = scale_max - scale_min;
280            if range.abs() < f64::EPSILON {
281                0.0
282            } else {
283                let npc = (value - scale_min) / range;
284                npc * viewport_cm
285            }
286        }
287        UnitType::Null => {
288            // Null units are resolved by the layout engine; before layout, they are 0.
289            0.0
290        }
291        UnitType::Snpc => {
292            // Square NPC: use the smaller of width and height
293            let min_dim = ctx.viewport_width_cm.min(ctx.viewport_height_cm);
294            value * min_dim
295        }
296        UnitType::StrWidth(s) => {
297            // Rough estimate: each character is ~0.6 * fontsize in points
298            let char_count = s.len() as f64;
299            char_count * ctx.fontsize_pt * 0.6 / 72.0 * 2.54
300        }
301        UnitType::StrHeight(_) => {
302            // Rough estimate: string height is ~1.0 * fontsize in points
303            ctx.fontsize_pt / 72.0 * 2.54
304        }
305        UnitType::GrobWidth(_) => {
306            // Grob dimensions require looking up the grob by name and measuring it.
307            // Estimate: typical grob is ~3cm wide (text label at default font).
308            // Full implementation needs GrobStore access which isn't available here.
309            value * 3.0
310        }
311        UnitType::GrobHeight(_) => {
312            // Estimate: typical grob is ~1cm tall (single line of text).
313            value * 1.0
314        }
315    }
316}
317
318impl Add for Unit {
319    type Output = Unit;
320
321    fn add(mut self, mut rhs: Unit) -> Unit {
322        self.values.append(&mut rhs.values);
323        self.units.append(&mut rhs.units);
324        self
325    }
326}
327
328impl Sub for Unit {
329    type Output = Unit;
330
331    fn sub(mut self, mut rhs: Unit) -> Unit {
332        // Negate all RHS values so that summing the compound unit produces a - b
333        for v in &mut rhs.values {
334            *v = -*v;
335        }
336        self.values.append(&mut rhs.values);
337        self.units.append(&mut rhs.units);
338        self
339    }
340}
341
342// endregion
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    const EPSILON: f64 = 1e-10;
349
350    fn approx_eq(a: f64, b: f64) -> bool {
351        (a - b).abs() < EPSILON
352    }
353
354    #[test]
355    fn unit_cm_passthrough() {
356        let u = Unit::cm(2.5);
357        let ctx = UnitContext::default();
358        let result = u.to_cm_scalar(&ctx, Axis::X);
359        assert!(approx_eq(result, 2.5), "expected 2.5, got {result}");
360    }
361
362    #[test]
363    fn unit_inches_conversion() {
364        let u = Unit::inches(1.0);
365        let ctx = UnitContext::default();
366        let result = u.to_cm_scalar(&ctx, Axis::X);
367        assert!(approx_eq(result, 2.54), "expected 2.54, got {result}");
368    }
369
370    #[test]
371    fn unit_mm_conversion() {
372        let u = Unit::mm(10.0);
373        let ctx = UnitContext::default();
374        let result = u.to_cm_scalar(&ctx, Axis::X);
375        assert!(approx_eq(result, 1.0), "expected 1.0, got {result}");
376    }
377
378    #[test]
379    fn unit_points_conversion() {
380        // 72 points = 1 inch = 2.54 cm
381        let u = Unit::points(72.0);
382        let ctx = UnitContext::default();
383        let result = u.to_cm_scalar(&ctx, Axis::X);
384        assert!(approx_eq(result, 2.54), "expected 2.54, got {result}");
385    }
386
387    #[test]
388    fn unit_npc_uses_viewport_dimension() {
389        let u = Unit::npc(0.5);
390        let ctx = UnitContext {
391            viewport_width_cm: 20.0,
392            viewport_height_cm: 10.0,
393            ..Default::default()
394        };
395        // X axis: 0.5 * 20 = 10
396        let x = u.to_cm_scalar(&ctx, Axis::X);
397        assert!(approx_eq(x, 10.0), "expected 10.0, got {x}");
398        // Y axis: 0.5 * 10 = 5
399        let y = u.to_cm_scalar(&ctx, Axis::Y);
400        assert!(approx_eq(y, 5.0), "expected 5.0, got {y}");
401    }
402
403    #[test]
404    fn unit_addition_combines_values() {
405        let a = Unit::cm(1.0);
406        let b = Unit::mm(5.0);
407        let combined = a + b;
408        assert_eq!(combined.len(), 2);
409        let ctx = UnitContext::default();
410        let result = combined.to_cm_scalar(&ctx, Axis::X);
411        assert!(approx_eq(result, 1.5), "expected 1.5, got {result}");
412    }
413
414    #[test]
415    fn unit_subtraction() {
416        let a = Unit::cm(3.0);
417        let b = Unit::cm(1.0);
418        let combined = a - b;
419        let ctx = UnitContext::default();
420        let result = combined.to_cm_scalar(&ctx, Axis::X);
421        assert!(approx_eq(result, 2.0), "expected 2.0, got {result}");
422    }
423
424    #[test]
425    fn unit_scale() {
426        let u = Unit::cm(2.0).scale(3.0);
427        let ctx = UnitContext::default();
428        let result = u.to_cm_scalar(&ctx, Axis::X);
429        assert!(approx_eq(result, 6.0), "expected 6.0, got {result}");
430    }
431
432    #[test]
433    fn unit_null_resolves_to_zero() {
434        let u = Unit::null(5.0);
435        let ctx = UnitContext::default();
436        let result = u.to_cm_scalar(&ctx, Axis::X);
437        assert!(approx_eq(result, 0.0), "expected 0.0, got {result}");
438    }
439
440    #[test]
441    fn unit_native_maps_through_scale() {
442        let u = Unit::new(50.0, UnitType::Native);
443        let ctx = UnitContext {
444            viewport_width_cm: 10.0,
445            xscale: (0.0, 100.0),
446            ..Default::default()
447        };
448        // 50 in [0, 100] -> npc 0.5 -> 0.5 * 10 = 5 cm
449        let result = u.to_cm_scalar(&ctx, Axis::X);
450        assert!(approx_eq(result, 5.0), "expected 5.0, got {result}");
451    }
452
453    #[test]
454    fn unit_is_absolute() {
455        assert!(Unit::cm(1.0).is_absolute());
456        assert!(Unit::inches(1.0).is_absolute());
457        assert!(Unit::mm(1.0).is_absolute());
458        assert!(Unit::points(1.0).is_absolute());
459        assert!(!Unit::npc(0.5).is_absolute());
460        assert!(!Unit::null(1.0).is_absolute());
461        assert!(!Unit::lines(1.0).is_absolute());
462    }
463
464    #[test]
465    fn unit_snpc_uses_smaller_dimension() {
466        let u = Unit::new(1.0, UnitType::Snpc);
467        let ctx = UnitContext {
468            viewport_width_cm: 20.0,
469            viewport_height_cm: 10.0,
470            ..Default::default()
471        };
472        // snpc uses min(20, 10) = 10
473        let result = u.to_cm_scalar(&ctx, Axis::X);
474        assert!(approx_eq(result, 10.0), "expected 10.0, got {result}");
475    }
476
477    #[test]
478    fn unit_empty() {
479        let u = Unit {
480            values: vec![],
481            units: vec![],
482        };
483        assert!(u.is_empty());
484        assert_eq!(u.to_cm(&UnitContext::default(), Axis::X).len(), 0);
485    }
486}