Skip to main content

ad_plugins_rs/
overlay.rs

1use std::sync::Arc;
2
3use ad_core_rs::ndarray::{NDArray, NDDataBuffer};
4use ad_core_rs::ndarray_pool::NDArrayPool;
5use ad_core_rs::plugin::runtime::{NDPluginProcess, ProcessResult};
6
7/// Shape to draw.
8#[derive(Debug, Clone)]
9pub enum OverlayShape {
10    Cross {
11        center_x: usize,
12        center_y: usize,
13        size: usize,
14    },
15    Rectangle {
16        x: usize,
17        y: usize,
18        width: usize,
19        height: usize,
20    },
21    Ellipse {
22        center_x: usize,
23        center_y: usize,
24        rx: usize,
25        ry: usize,
26    },
27    Text {
28        x: usize,
29        y: usize,
30        /// X extent (SizeX) — characters past `x + size_x` are not drawn,
31        /// matching C++ `xmax = PositionX + SizeX`.
32        size_x: usize,
33        /// Y extent (SizeY) — drawing stops at `min(y + size_y, y + font.height)`.
34        size_y: usize,
35        text: String,
36        /// Bitmap font index (0..=3): C++ `NDPluginOverlayTextFontBitmaps`.
37        font: usize,
38        /// Optional strftime format. When non-empty, the formatted NDArray
39        /// timestamp is appended to `text` (C++ `TimeStampFormat`).
40        timestamp_format: String,
41    },
42}
43
44/// Draw mode.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum DrawMode {
47    Set,
48    XOR,
49}
50
51/// A single overlay definition.
52#[derive(Debug, Clone)]
53pub struct OverlayDef {
54    pub shape: OverlayShape,
55    pub draw_mode: DrawMode,
56    pub color: [u8; 3], // RGB color; for Mono, color[1] (green) is used
57    pub width_x: usize, // line thickness in X direction (0 or 1 = 1px)
58    pub width_y: usize, // line thickness in Y direction (0 or 1 = 1px)
59}
60
61// ---------------------------------------------------------------------------
62// Bitmap fonts — ported from ADCore NDPluginOverlayTextFont.cpp
63// ---------------------------------------------------------------------------
64
65use crate::overlay_font::{BitmapFont, FONTS};
66
67/// Number of selectable bitmap fonts (C++ `NDPluginOverlayTextFontBitmapTypeN`).
68pub const NUM_FONTS: usize = 4;
69
70/// Resolve a font index to its bitmap descriptor, clamping out-of-range
71/// indices to font 0 (C++ guards `Font >= 0 && Font < N`).
72fn font_for(index: usize) -> &'static BitmapFont {
73    &FONTS[index.min(NUM_FONTS - 1)]
74}
75
76/// Test whether bit `col` of character `ch` row `row` is set in `font`.
77///
78/// Mirrors C++ `NDPluginOverlay.cpp` text rendering: each character occupies
79/// `height` rows of `bytes_per_char` bytes; bit order is MSB-first within
80/// each byte (`mask = 0x80`). Characters below `first_char` or above the
81/// font range render blank.
82fn font_pixel(font: &BitmapFont, ch: char, row: usize, col: usize) -> bool {
83    let code = ch as u32;
84    if code < font.first_char as u32 {
85        return false;
86    }
87    let ci = (code - font.first_char as u32) as usize;
88    if ci >= font.num_chars || row >= font.height || col >= font.width {
89        return false;
90    }
91    let byte_in_row = col / 8;
92    let bit = 7 - (col % 8);
93    let offset = (font.height * ci + row) * font.bytes_per_char + byte_in_row;
94    (font.bitmap[offset] >> bit) & 1 != 0
95}
96
97/// Format an EPICS timestamp with a strftime-style format string.
98///
99/// Mirrors C++ `epicsTimeToStrftime` for the conversion specifiers commonly
100/// used in AreaDetector overlay configs: `%Y %m %d %H %M %S %f %%`. `%f` is
101/// the fractional seconds in microseconds (6 digits). Unknown specifiers are
102/// passed through verbatim.
103fn format_epics_time(ts: ad_core_rs::timestamp::EpicsTimestamp, fmt: &str) -> String {
104    // Decompose the UTC time-of-day from the EPICS timestamp.
105    let secs = ts.sec as u64 + 631_152_000; // EPICS epoch -> Unix epoch
106    let days = secs / 86_400;
107    let tod = secs % 86_400;
108    let (hour, minute, second) = (tod / 3600, (tod % 3600) / 60, tod % 60);
109
110    // Civil date from days since Unix epoch (Howard Hinnant's algorithm).
111    let z = days as i64 + 719_468;
112    let era = z.div_euclid(146_097);
113    let doe = z - era * 146_097;
114    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
115    let y = yoe + era * 400;
116    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
117    let mp = (5 * doy + 2) / 153;
118    let day = doy - (153 * mp + 2) / 5 + 1;
119    let month = if mp < 10 { mp + 3 } else { mp - 9 };
120    let year = if month <= 2 { y + 1 } else { y };
121
122    let mut out = String::with_capacity(fmt.len() + 16);
123    let mut chars = fmt.chars().peekable();
124    while let Some(c) = chars.next() {
125        if c != '%' {
126            out.push(c);
127            continue;
128        }
129        match chars.next() {
130            Some('Y') => out.push_str(&format!("{year:04}")),
131            Some('m') => out.push_str(&format!("{month:02}")),
132            Some('d') => out.push_str(&format!("{day:02}")),
133            Some('H') => out.push_str(&format!("{hour:02}")),
134            Some('M') => out.push_str(&format!("{minute:02}")),
135            Some('S') => out.push_str(&format!("{second:02}")),
136            Some('f') => out.push_str(&format!("{:06}", ts.nsec / 1000)),
137            Some('%') => out.push('%'),
138            Some(other) => {
139                out.push('%');
140                out.push(other);
141            }
142            None => out.push('%'),
143        }
144    }
145    out
146}
147
148// ---------------------------------------------------------------------------
149// Per-type drawing via macro
150// ---------------------------------------------------------------------------
151
152macro_rules! draw_on_typed_buffer {
153    ($data:expr, $T:ty, $overlays:expr, $w:expr, $h:expr, $ts:expr, xor) => {{
154        draw_on_typed_buffer!(@inner $data, $T, $overlays, $w, $h, $ts, |data: &mut [$T], idx: usize, mode: DrawMode, value: $T| {
155            match mode {
156                DrawMode::Set => data[idx] = value,
157                DrawMode::XOR => data[idx] ^= value,
158            }
159        });
160    }};
161    ($data:expr, $T:ty, $overlays:expr, $w:expr, $h:expr, $ts:expr, set_only) => {{
162        draw_on_typed_buffer!(@inner $data, $T, $overlays, $w, $h, $ts, |data: &mut [$T], idx: usize, _mode: DrawMode, value: $T| {
163            data[idx] = value;
164        });
165    }};
166    (@inner $data:expr, $T:ty, $overlays:expr, $w:expr, $h:expr, $ts:expr, $set_fn:expr) => {{
167        let data: &mut [$T] = $data;
168        let w: usize = $w;
169        let h: usize = $h;
170        let array_ts: ad_core_rs::timestamp::EpicsTimestamp = $ts;
171        let set_fn = $set_fn;
172
173        for overlay in $overlays.iter() {
174            // C++ uses pOverlay->green for mono overlays
175            let value: $T = overlay.color[1] as $T;
176            let wx = overlay.width_x.max(1);
177            let wy = overlay.width_y.max(1);
178
179            // Closure to set a single pixel
180            let mut set_pixel = |x: usize, y: usize| {
181                if x < w && y < h {
182                    let idx = y * w + x;
183                    set_fn(data, idx, overlay.draw_mode, value);
184                }
185            };
186
187            match &overlay.shape {
188                OverlayShape::Cross { center_x, center_y, size } => {
189                    // C++ doOverlayT Cross: xwide/ywide are WidthX/2, WidthY/2
190                    // (half-widths). Rows inside the horizontal band
191                    // [ycent-ywide, ycent+ywide] draw the full arm; other rows
192                    // draw only the vertical strip [xcent-xwide, xcent+xwide].
193                    // This structure visits every pixel exactly once, so the
194                    // center pixel is not double-XOR'd.
195                    let cx = *center_x as i64;
196                    let cy = *center_y as i64;
197                    let half = (*size / 2) as i64;
198                    let xwide = (wx / 2) as i64;
199                    let ywide = (wy / 2) as i64;
200                    let mut put = |x: i64, y: i64| {
201                        if x >= 0 && y >= 0 {
202                            set_pixel(x as usize, y as usize);
203                        }
204                    };
205                    for iy in (cy - half)..=(cy + half) {
206                        if iy >= cy - ywide && iy <= cy + ywide {
207                            // Inside the horizontal band: full row.
208                            for ix in (cx - half)..=(cx + half) {
209                                put(ix, iy);
210                            }
211                        } else {
212                            // Outside the band: vertical strip only.
213                            for ix in (cx - xwide)..=(cx + xwide) {
214                                put(ix, iy);
215                            }
216                        }
217                    }
218                }
219                OverlayShape::Rectangle { x, y, width, height } => {
220                    // Border thickness grows inward
221                    let bx = wx.min(*width);
222                    let by = wy.min(*height);
223                    // Top edge
224                    for dy in 0..by {
225                        for dx in 0..*width {
226                            set_pixel(x + dx, y + dy);
227                        }
228                    }
229                    // Bottom edge
230                    for dy in 0..by {
231                        if *height > dy {
232                            for dx in 0..*width {
233                                set_pixel(x + dx, y + height - 1 - dy);
234                            }
235                        }
236                    }
237                    // Left edge (between top and bottom borders)
238                    let inner_start = by;
239                    let inner_end = height.saturating_sub(by);
240                    for dy in inner_start..inner_end {
241                        for t in 0..bx {
242                            set_pixel(x + t, y + dy);
243                        }
244                    }
245                    // Right edge (between top and bottom borders)
246                    for dy in inner_start..inner_end {
247                        for t in 0..bx {
248                            if *width > t {
249                                set_pixel(x + width - 1 - t, y + dy);
250                            }
251                        }
252                    }
253                }
254                OverlayShape::Ellipse { center_x, center_y, rx, ry } => {
255                    // C++ doOverlayT Ellipse: parametric over the first
256                    // quadrant, mirrored to the other three; for each of
257                    // `xwide` thickness layers shrink the radii by jj. C++
258                    // sorts+uniques the resulting pixel list before drawing
259                    // "or the XOR draw mode won't work because the pixel will
260                    // be set and then unset". We dedup pixels here for the
261                    // same reason.
262                    let cx = *center_x as i64;
263                    let cy = *center_y as i64;
264                    let xsize = *rx as i64;
265                    let ysize = *ry as i64;
266                    // C++: xwide = MIN(WidthX, SizeX-1); SizeX = 2*rx.
267                    let xwide = (wx as i64).min((2 * xsize - 1).max(0));
268                    let n_steps = (2 * (xsize + ysize)).max(1);
269                    let theta_step = std::f64::consts::FRAC_PI_2 / n_steps as f64;
270                    let mut pixels: Vec<(i64, i64)> = Vec::new();
271                    for ii in 0..=n_steps {
272                        let theta = ii as f64 * theta_step;
273                        for jj in 0..xwide.max(1) {
274                            let ix = (((xsize - jj) as f64) * theta.cos() + 0.5) as i64;
275                            let iy = (((ysize - jj) as f64) * theta.sin() + 0.5) as i64;
276                            pixels.push((cx + ix, cy + iy));
277                            pixels.push((cx + ix, cy - iy));
278                            pixels.push((cx - ix, cy + iy));
279                            pixels.push((cx - ix, cy - iy));
280                        }
281                    }
282                    // Remove duplicates so XOR mode does not self-cancel.
283                    pixels.sort_unstable();
284                    pixels.dedup();
285                    for (px, py) in pixels {
286                        if px >= 0 && py >= 0 {
287                            set_pixel(px as usize, py as usize);
288                        }
289                    }
290                }
291                OverlayShape::Text { x, y, size_x, size_y, text, font, timestamp_format } => {
292                    // C++ NDPluginOverlay.cpp text path: a fixed-cell bitmap
293                    // font (no scaling); characters advance by the full font
294                    // width; xmax = PositionX + SizeX clips trailing chars;
295                    // ymax = min(PositionY + SizeY, PositionY + font.height).
296                    let bmp = font_for(*font);
297                    // Append the formatted timestamp when a format is set
298                    // (C++ epicsTimeToStrftime + DisplayText concatenation).
299                    let rendered = if timestamp_format.is_empty() {
300                        text.clone()
301                    } else {
302                        format!("{}{}", text, format_epics_time(array_ts, timestamp_format))
303                    };
304                    let xmin = *x;
305                    let xmax = x.saturating_add(*size_x);
306                    let ymax = y
307                        .saturating_add(*size_y)
308                        .min(y.saturating_add(bmp.height));
309                    for (ci, ch) in rendered.chars().enumerate() {
310                        let char_x0 = xmin + ci * bmp.width;
311                        if char_x0 >= xmax {
312                            break; // none of this character fits
313                        }
314                        for row in 0..bmp.height {
315                            let iy = *y + row;
316                            if iy >= ymax {
317                                break;
318                            }
319                            for col in 0..bmp.width {
320                                let ix = char_x0 + col;
321                                if ix >= xmax {
322                                    break;
323                                }
324                                if font_pixel(bmp, ch, row, col) {
325                                    set_pixel(ix, iy);
326                                }
327                            }
328                        }
329                    }
330                }
331            }
332        }
333    }};
334}
335
336/// Draw overlays on a 2D array. Supports I8, U8, I16, U16, I32, U32, I64, U64, F32, F64.
337pub fn draw_overlays(src: &NDArray, overlays: &[OverlayDef]) -> NDArray {
338    let mut arr = src.clone();
339    if arr.dims.len() < 2 {
340        return arr;
341    }
342    let w = arr.dims[0].size;
343    let h = arr.dims[1].size;
344
345    match &mut arr.data {
346        NDDataBuffer::U8(data) => {
347            draw_on_typed_buffer!(data.as_mut_slice(), u8, overlays, w, h, arr.timestamp, xor);
348        }
349        NDDataBuffer::U16(data) => {
350            draw_on_typed_buffer!(data.as_mut_slice(), u16, overlays, w, h, arr.timestamp, xor);
351        }
352        NDDataBuffer::I16(data) => {
353            draw_on_typed_buffer!(data.as_mut_slice(), i16, overlays, w, h, arr.timestamp, xor);
354        }
355        NDDataBuffer::I32(data) => {
356            draw_on_typed_buffer!(data.as_mut_slice(), i32, overlays, w, h, arr.timestamp, xor);
357        }
358        NDDataBuffer::U32(data) => {
359            draw_on_typed_buffer!(data.as_mut_slice(), u32, overlays, w, h, arr.timestamp, xor);
360        }
361        NDDataBuffer::F32(data) => {
362            draw_on_typed_buffer!(
363                data.as_mut_slice(),
364                f32,
365                overlays,
366                w,
367                h,
368                arr.timestamp,
369                set_only
370            );
371        }
372        NDDataBuffer::F64(data) => {
373            draw_on_typed_buffer!(
374                data.as_mut_slice(),
375                f64,
376                overlays,
377                w,
378                h,
379                arr.timestamp,
380                set_only
381            );
382        }
383        NDDataBuffer::I8(data) => {
384            draw_on_typed_buffer!(data.as_mut_slice(), i8, overlays, w, h, arr.timestamp, xor);
385        }
386        NDDataBuffer::I64(data) => {
387            draw_on_typed_buffer!(data.as_mut_slice(), i64, overlays, w, h, arr.timestamp, xor);
388        }
389        NDDataBuffer::U64(data) => {
390            draw_on_typed_buffer!(data.as_mut_slice(), u64, overlays, w, h, arr.timestamp, xor);
391        }
392    }
393
394    arr
395}
396
397/// Maximum number of overlays.
398const MAX_OVERLAYS: usize = 8;
399
400/// Runtime overlay state — one per addr (0..7).
401#[derive(Debug, Clone)]
402struct OverlaySlot {
403    use_overlay: bool,
404    shape: i32,     // 0=Cross, 1=Rectangle, 2=Ellipse, 3=Text
405    draw_mode: i32, // 0=Set, 1=XOR
406    position_x: usize,
407    position_y: usize,
408    // Stored CenterX/CenterY (signed, like the C++ param) so a SizeX change
409    // with freeze OFF can recover the frozen center exactly.
410    center_x: i32,
411    center_y: i32,
412    size_x: usize,
413    size_y: usize,
414    width_x: usize,
415    width_y: usize,
416    red: u8,
417    green: u8,
418    blue: u8,
419    display_text: String,
420    timestamp_format: String,
421    font: usize,
422    /// C++ `freezePositionX`: true once PositionX was written more recently
423    /// than CenterX. A SizeX change then keeps PositionX fixed (moving the
424    /// center); false keeps CenterX fixed (moving the position).
425    freeze_position_x: bool,
426    freeze_position_y: bool,
427}
428
429impl Default for OverlaySlot {
430    fn default() -> Self {
431        Self {
432            use_overlay: false,
433            shape: 1, // Rectangle
434            draw_mode: 0,
435            position_x: 0,
436            position_y: 0,
437            center_x: 0,
438            center_y: 0,
439            size_x: 0,
440            size_y: 0,
441            width_x: 1,
442            width_y: 1,
443            red: 255,
444            green: 0,
445            blue: 0,
446            display_text: String::new(),
447            timestamp_format: String::new(),
448            font: 0,
449            freeze_position_x: true,
450            freeze_position_y: true,
451        }
452    }
453}
454
455impl OverlaySlot {
456    fn to_overlay_def(&self) -> Option<OverlayDef> {
457        if !self.use_overlay {
458            return None;
459        }
460        let draw_mode = if self.draw_mode == 1 {
461            DrawMode::XOR
462        } else {
463            DrawMode::Set
464        };
465        let color = [self.red, self.green, self.blue];
466        let shape = match self.shape {
467            0 => OverlayShape::Cross {
468                center_x: self.position_x + self.size_x / 2,
469                center_y: self.position_y + self.size_y / 2,
470                size: self.size_x.max(self.size_y),
471            },
472            1 => OverlayShape::Rectangle {
473                x: self.position_x,
474                y: self.position_y,
475                width: self.size_x,
476                height: self.size_y,
477            },
478            2 => OverlayShape::Ellipse {
479                center_x: self.position_x + self.size_x / 2,
480                center_y: self.position_y + self.size_y / 2,
481                rx: self.size_x / 2,
482                ry: self.size_y / 2,
483            },
484            3 => OverlayShape::Text {
485                x: self.position_x,
486                y: self.position_y,
487                size_x: self.size_x,
488                size_y: self.size_y,
489                text: self.display_text.clone(),
490                font: self.font,
491                timestamp_format: self.timestamp_format.clone(),
492            },
493            _ => OverlayShape::Rectangle {
494                x: self.position_x,
495                y: self.position_y,
496                width: self.size_x,
497                height: self.size_y,
498            },
499        };
500        Some(OverlayDef {
501            shape,
502            draw_mode,
503            color,
504            width_x: self.width_x,
505            width_y: self.width_y,
506        })
507    }
508}
509
510/// Param indices for per-overlay params.
511#[derive(Default)]
512struct OverlayParamIndices {
513    use_overlay: Option<usize>,
514    position_x: Option<usize>,
515    position_y: Option<usize>,
516    center_x: Option<usize>,
517    center_y: Option<usize>,
518    size_x: Option<usize>,
519    size_y: Option<usize>,
520    width_x: Option<usize>,
521    width_y: Option<usize>,
522    shape: Option<usize>,
523    draw_mode: Option<usize>,
524    red: Option<usize>,
525    green: Option<usize>,
526    blue: Option<usize>,
527    display_text: Option<usize>,
528    timestamp_format: Option<usize>,
529    font: Option<usize>,
530}
531
532/// Pure overlay processing logic with runtime-configurable overlays.
533pub struct OverlayProcessor {
534    slots: [OverlaySlot; MAX_OVERLAYS],
535    params: OverlayParamIndices,
536}
537
538impl OverlayProcessor {
539    pub fn new(overlays: Vec<OverlayDef>) -> Self {
540        let mut slots: [OverlaySlot; MAX_OVERLAYS] = Default::default();
541        for (i, o) in overlays.into_iter().enumerate().take(MAX_OVERLAYS) {
542            let slot = &mut slots[i];
543            slot.use_overlay = true;
544            slot.draw_mode = if o.draw_mode == DrawMode::XOR { 1 } else { 0 };
545            slot.red = o.color[0];
546            slot.green = o.color[1];
547            slot.blue = o.color[2];
548            slot.width_x = o.width_x;
549            slot.width_y = o.width_y;
550            match o.shape {
551                OverlayShape::Cross {
552                    center_x,
553                    center_y,
554                    size,
555                } => {
556                    slot.shape = 0;
557                    slot.position_x = center_x.saturating_sub(size / 2);
558                    slot.position_y = center_y.saturating_sub(size / 2);
559                    slot.size_x = size;
560                    slot.size_y = size;
561                }
562                OverlayShape::Rectangle {
563                    x,
564                    y,
565                    width,
566                    height,
567                } => {
568                    slot.shape = 1;
569                    slot.position_x = x;
570                    slot.position_y = y;
571                    slot.size_x = width;
572                    slot.size_y = height;
573                }
574                OverlayShape::Ellipse {
575                    center_x,
576                    center_y,
577                    rx,
578                    ry,
579                } => {
580                    slot.shape = 2;
581                    slot.position_x = center_x.saturating_sub(rx);
582                    slot.position_y = center_y.saturating_sub(ry);
583                    slot.size_x = rx * 2;
584                    slot.size_y = ry * 2;
585                }
586                OverlayShape::Text {
587                    x,
588                    y,
589                    size_x,
590                    size_y,
591                    text,
592                    font,
593                    timestamp_format,
594                } => {
595                    slot.shape = 3;
596                    slot.position_x = x;
597                    slot.position_y = y;
598                    slot.size_x = size_x;
599                    slot.size_y = size_y;
600                    slot.display_text = text;
601                    slot.timestamp_format = timestamp_format;
602                    slot.font = font.min(NUM_FONTS - 1);
603                }
604            }
605        }
606        Self {
607            slots,
608            params: OverlayParamIndices::default(),
609        }
610    }
611
612    fn build_active_overlays(&self) -> Vec<OverlayDef> {
613        self.slots
614            .iter()
615            .filter_map(|s| s.to_overlay_def())
616            .collect()
617    }
618}
619
620impl NDPluginProcess for OverlayProcessor {
621    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
622        let active = self.build_active_overlays();
623        let out = draw_overlays(array, &active);
624        ProcessResult::arrays(vec![Arc::new(out)])
625    }
626
627    fn plugin_type(&self) -> &str {
628        "NDPluginOverlay"
629    }
630
631    fn register_params(
632        &mut self,
633        base: &mut asyn_rs::port::PortDriverBase,
634    ) -> asyn_rs::error::AsynResult<()> {
635        use asyn_rs::param::ParamType;
636        base.create_param("MAX_SIZE_X", ParamType::Int32)?;
637        base.create_param("MAX_SIZE_Y", ParamType::Int32)?;
638        base.create_param("NAME", ParamType::Octet)?;
639        base.create_param("USE", ParamType::Int32)?;
640        base.create_param("OVERLAY_POSITION_X", ParamType::Int32)?;
641        base.create_param("OVERLAY_POSITION_Y", ParamType::Int32)?;
642        base.create_param("OVERLAY_CENTER_X", ParamType::Int32)?;
643        base.create_param("OVERLAY_CENTER_Y", ParamType::Int32)?;
644        base.create_param("OVERLAY_SIZE_X", ParamType::Int32)?;
645        base.create_param("OVERLAY_SIZE_Y", ParamType::Int32)?;
646        base.create_param("OVERLAY_WIDTH_X", ParamType::Int32)?;
647        base.create_param("OVERLAY_WIDTH_Y", ParamType::Int32)?;
648        base.create_param("OVERLAY_SHAPE", ParamType::Int32)?;
649        base.create_param("OVERLAY_DRAW_MODE", ParamType::Int32)?;
650        base.create_param("OVERLAY_RED", ParamType::Int32)?;
651        base.create_param("OVERLAY_GREEN", ParamType::Int32)?;
652        base.create_param("OVERLAY_BLUE", ParamType::Int32)?;
653        base.create_param("OVERLAY_DISPLAY_TEXT", ParamType::Octet)?;
654        base.create_param("OVERLAY_TIMESTAMP_FORMAT", ParamType::Octet)?;
655        base.create_param("OVERLAY_FONT", ParamType::Int32)?;
656
657        self.params.use_overlay = base.find_param("USE");
658        self.params.position_x = base.find_param("OVERLAY_POSITION_X");
659        self.params.position_y = base.find_param("OVERLAY_POSITION_Y");
660        self.params.center_x = base.find_param("OVERLAY_CENTER_X");
661        self.params.center_y = base.find_param("OVERLAY_CENTER_Y");
662        self.params.size_x = base.find_param("OVERLAY_SIZE_X");
663        self.params.size_y = base.find_param("OVERLAY_SIZE_Y");
664        self.params.width_x = base.find_param("OVERLAY_WIDTH_X");
665        self.params.width_y = base.find_param("OVERLAY_WIDTH_Y");
666        self.params.shape = base.find_param("OVERLAY_SHAPE");
667        self.params.draw_mode = base.find_param("OVERLAY_DRAW_MODE");
668        self.params.red = base.find_param("OVERLAY_RED");
669        self.params.green = base.find_param("OVERLAY_GREEN");
670        self.params.blue = base.find_param("OVERLAY_BLUE");
671        self.params.display_text = base.find_param("OVERLAY_DISPLAY_TEXT");
672        self.params.timestamp_format = base.find_param("OVERLAY_TIMESTAMP_FORMAT");
673        self.params.font = base.find_param("OVERLAY_FONT");
674        Ok(())
675    }
676
677    fn on_param_change(
678        &mut self,
679        reason: usize,
680        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
681    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
682        use ad_core_rs::plugin::runtime::{ParamChangeResult, ParamChangeValue, ParamUpdate};
683
684        let idx = params.addr as usize;
685        if idx >= MAX_OVERLAYS {
686            return ParamChangeResult::updates(vec![]);
687        }
688        let slot = &mut self.slots[idx];
689        let mut updates = Vec::new();
690
691        // C++ NDPluginOverlay::writeInt32 freeze semantics. Position/Center/
692        // Size are stored as signed i32 so the center<->position recompute
693        // can pass through negative intermediates exactly like C++.
694        if Some(reason) == self.params.use_overlay {
695            slot.use_overlay = params.value.as_i32() != 0;
696        } else if Some(reason) == self.params.shape {
697            slot.shape = params.value.as_i32();
698        } else if Some(reason) == self.params.draw_mode {
699            slot.draw_mode = params.value.as_i32();
700        } else if Some(reason) == self.params.position_x {
701            // PositionX written -> CenterX = PositionX + SizeX/2; freeze ON.
702            let pos = params.value.as_i32().max(0);
703            slot.position_x = pos as usize;
704            slot.freeze_position_x = true;
705            slot.center_x = pos + (slot.size_x / 2) as i32;
706            if let Some(ci) = self.params.center_x {
707                updates.push(ParamUpdate::int32_addr(ci, idx as i32, slot.center_x));
708            }
709        } else if Some(reason) == self.params.position_y {
710            let pos = params.value.as_i32().max(0);
711            slot.position_y = pos as usize;
712            slot.freeze_position_y = true;
713            slot.center_y = pos + (slot.size_y / 2) as i32;
714            if let Some(ci) = self.params.center_y {
715                updates.push(ParamUpdate::int32_addr(ci, idx as i32, slot.center_y));
716            }
717        } else if Some(reason) == self.params.center_x {
718            // CenterX written -> PositionX = CenterX - SizeX/2; freeze OFF.
719            slot.center_x = params.value.as_i32();
720            let pos = slot.center_x - (slot.size_x / 2) as i32;
721            slot.position_x = pos.max(0) as usize;
722            slot.freeze_position_x = false;
723            if let Some(pi) = self.params.position_x {
724                updates.push(ParamUpdate::int32_addr(pi, idx as i32, pos));
725            }
726        } else if Some(reason) == self.params.center_y {
727            slot.center_y = params.value.as_i32();
728            let pos = slot.center_y - (slot.size_y / 2) as i32;
729            slot.position_y = pos.max(0) as usize;
730            slot.freeze_position_y = false;
731            if let Some(pi) = self.params.position_y {
732                updates.push(ParamUpdate::int32_addr(pi, idx as i32, pos));
733            }
734        } else if Some(reason) == self.params.size_x {
735            // SizeX written: if PositionX is frozen keep it and move the
736            // center; otherwise keep the center and move the position.
737            slot.size_x = params.value.as_i32().max(0) as usize;
738            if slot.freeze_position_x {
739                slot.center_x = slot.position_x as i32 + (slot.size_x / 2) as i32;
740                if let Some(ci) = self.params.center_x {
741                    updates.push(ParamUpdate::int32_addr(ci, idx as i32, slot.center_x));
742                }
743            } else {
744                let pos = slot.center_x - (slot.size_x / 2) as i32;
745                slot.position_x = pos.max(0) as usize;
746                if let Some(pi) = self.params.position_x {
747                    updates.push(ParamUpdate::int32_addr(pi, idx as i32, pos));
748                }
749            }
750        } else if Some(reason) == self.params.size_y {
751            slot.size_y = params.value.as_i32().max(0) as usize;
752            if slot.freeze_position_y {
753                slot.center_y = slot.position_y as i32 + (slot.size_y / 2) as i32;
754                if let Some(ci) = self.params.center_y {
755                    updates.push(ParamUpdate::int32_addr(ci, idx as i32, slot.center_y));
756                }
757            } else {
758                let pos = slot.center_y - (slot.size_y / 2) as i32;
759                slot.position_y = pos.max(0) as usize;
760                if let Some(pi) = self.params.position_y {
761                    updates.push(ParamUpdate::int32_addr(pi, idx as i32, pos));
762                }
763            }
764        } else if Some(reason) == self.params.width_x {
765            slot.width_x = params.value.as_i32().max(0) as usize;
766        } else if Some(reason) == self.params.width_y {
767            slot.width_y = params.value.as_i32().max(0) as usize;
768        } else if Some(reason) == self.params.red {
769            slot.red = params.value.as_i32().clamp(0, 255) as u8;
770        } else if Some(reason) == self.params.green {
771            slot.green = params.value.as_i32().clamp(0, 255) as u8;
772        } else if Some(reason) == self.params.blue {
773            slot.blue = params.value.as_i32().clamp(0, 255) as u8;
774        } else if Some(reason) == self.params.display_text {
775            if let ParamChangeValue::Octet(s) = &params.value {
776                slot.display_text = s.clone();
777            }
778        } else if Some(reason) == self.params.timestamp_format {
779            if let ParamChangeValue::Octet(s) = &params.value {
780                slot.timestamp_format = s.clone();
781            }
782        } else if Some(reason) == self.params.font {
783            slot.font = (params.value.as_i32().max(0) as usize).min(NUM_FONTS - 1);
784        }
785
786        ParamChangeResult::updates(updates)
787    }
788}
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793    use ad_core_rs::ndarray::{NDDataType, NDDimension};
794
795    fn make_8x8() -> NDArray {
796        NDArray::new(
797            vec![NDDimension::new(8), NDDimension::new(8)],
798            NDDataType::UInt8,
799        )
800    }
801
802    #[test]
803    fn test_rectangle() {
804        let arr = make_8x8();
805        let overlays = vec![OverlayDef {
806            shape: OverlayShape::Rectangle {
807                x: 1,
808                y: 1,
809                width: 4,
810                height: 3,
811            },
812            draw_mode: DrawMode::Set,
813            color: [0, 255, 0],
814            width_x: 1,
815            width_y: 1,
816        }];
817
818        let out = draw_overlays(&arr, &overlays);
819        if let NDDataBuffer::U8(ref v) = out.data {
820            // Top edge of rectangle at y=1, x=1..4
821            assert_eq!(v[1 * 8 + 1], 255);
822            assert_eq!(v[1 * 8 + 2], 255);
823            assert_eq!(v[1 * 8 + 3], 255);
824            assert_eq!(v[1 * 8 + 4], 255);
825            // Inside should still be 0
826            assert_eq!(v[2 * 8 + 2], 0);
827        }
828    }
829
830    #[test]
831    fn test_xor_mode() {
832        let mut arr = make_8x8();
833        if let NDDataBuffer::U8(ref mut v) = arr.data {
834            v[0] = 0xFF;
835        }
836
837        let overlays = vec![OverlayDef {
838            shape: OverlayShape::Cross {
839                center_x: 0,
840                center_y: 0,
841                size: 2,
842            },
843            draw_mode: DrawMode::XOR,
844            color: [0, 0xFF, 0],
845            width_x: 1,
846            width_y: 1,
847        }];
848
849        let out = draw_overlays(&arr, &overlays);
850        if let NDDataBuffer::U8(ref v) = out.data {
851            // C++ Cross visits each pixel exactly once, so the center is
852            // XOR'd a single time: 0xFF ^ 0xFF = 0x00 (not double-toggled).
853            assert_eq!(v[0], 0x00);
854            // Neighbor (1,0) drawn once: 0x00 ^ 0xFF = 0xFF
855            assert_eq!(v[1], 0xFF);
856            // Pixel (0,1) drawn once: 0x00 ^ 0xFF = 0xFF
857            assert_eq!(v[1 * 8], 0xFF);
858        }
859    }
860
861    #[test]
862    fn test_cross() {
863        let arr = make_8x8();
864        let overlays = vec![OverlayDef {
865            shape: OverlayShape::Cross {
866                center_x: 4,
867                center_y: 4,
868                size: 4,
869            },
870            draw_mode: DrawMode::Set,
871            color: [0, 200, 0],
872            width_x: 1,
873            width_y: 1,
874        }];
875
876        let out = draw_overlays(&arr, &overlays);
877        if let NDDataBuffer::U8(ref v) = out.data {
878            assert_eq!(v[4 * 8 + 4], 200); // center
879            assert_eq!(v[4 * 8 + 6], 200); // right arm
880            assert_eq!(v[6 * 8 + 4], 200); // bottom arm
881        }
882    }
883
884    #[test]
885    fn test_text_rendering() {
886        // Render "Hi" at (0,0) with bitmap font 0 (6x13). Each glyph is a
887        // 6-px-wide cell; the rendered pixels must match font_pixel().
888        let arr = NDArray::new(
889            vec![NDDimension::new(40), NDDimension::new(20)],
890            NDDataType::UInt8,
891        );
892        let overlays = vec![OverlayDef {
893            shape: OverlayShape::Text {
894                x: 0,
895                y: 0,
896                size_x: 40,
897                size_y: 20,
898                text: "Hi".to_string(),
899                font: 0,
900                timestamp_format: String::new(),
901            },
902            draw_mode: DrawMode::Set,
903            color: [0, 255, 0],
904            width_x: 1,
905            width_y: 1,
906        }];
907
908        let out = draw_overlays(&arr, &overlays);
909        if let NDDataBuffer::U8(ref v) = out.data {
910            let w = 40;
911            let bmp = font_for(0);
912            // Every drawn pixel of each glyph must agree with font_pixel().
913            for (ci, ch) in "Hi".chars().enumerate() {
914                for row in 0..bmp.height {
915                    for col in 0..bmp.width {
916                        let expect = font_pixel(bmp, ch, row, col);
917                        let px = v[row * w + ci * bmp.width + col];
918                        assert_eq!(px != 0, expect, "glyph {ch} pixel ({col},{row}) mismatch");
919                    }
920                }
921            }
922            // At least some pixels must be drawn (font is not all-blank).
923            assert!(v.iter().any(|&p| p != 0), "text rendered nothing");
924        }
925    }
926
927    #[test]
928    fn test_text_font_selection_differs() {
929        // Fonts 0 (6x13) and 2 (9x15) have different cell sizes; the 9x15
930        // font extends past column 6, so the rendered pixel sets differ.
931        let render = |font: usize| -> usize {
932            let arr = NDArray::new(
933                vec![NDDimension::new(80), NDDimension::new(20)],
934                NDDataType::UInt8,
935            );
936            let ov = vec![OverlayDef {
937                shape: OverlayShape::Text {
938                    x: 0,
939                    y: 0,
940                    size_x: 80,
941                    size_y: 20,
942                    text: "W".to_string(),
943                    font,
944                    timestamp_format: String::new(),
945                },
946                draw_mode: DrawMode::Set,
947                color: [0, 255, 0],
948                width_x: 1,
949                width_y: 1,
950            }];
951            let out = draw_overlays(&arr, &ov);
952            if let NDDataBuffer::U8(v) = &out.data {
953                v.iter().filter(|&&p| p != 0).count()
954            } else {
955                0
956            }
957        };
958        assert_ne!(render(0), render(2), "font selection had no effect");
959    }
960
961    #[test]
962    fn test_text_size_x_clips_characters() {
963        // SizeX limits how many characters fit: with size_x = 6 only the
964        // first 6-px-wide glyph is drawn (font 0).
965        let arr = NDArray::new(
966            vec![NDDimension::new(40), NDDimension::new(20)],
967            NDDataType::UInt8,
968        );
969        let ov = vec![OverlayDef {
970            shape: OverlayShape::Text {
971                x: 0,
972                y: 0,
973                size_x: 6,
974                size_y: 20,
975                text: "WW".to_string(),
976                font: 0,
977                timestamp_format: String::new(),
978            },
979            draw_mode: DrawMode::Set,
980            color: [0, 255, 0],
981            width_x: 1,
982            width_y: 1,
983        }];
984        let out = draw_overlays(&arr, &ov);
985        if let NDDataBuffer::U8(v) = &out.data {
986            let w = 40;
987            // The second glyph would start at column 6 == xmax, so nothing
988            // past column 5 may be set.
989            for row in 0..font_for(0).height {
990                for col in 6..40 {
991                    assert_eq!(v[row * w + col], 0, "pixel ({col},{row}) past xmax");
992                }
993            }
994        }
995    }
996
997    #[test]
998    fn test_u16_overlay() {
999        let arr = NDArray::new(
1000            vec![NDDimension::new(8), NDDimension::new(8)],
1001            NDDataType::UInt16,
1002        );
1003        // Fill with zeros (already done by NDArray::new)
1004        let overlays = vec![OverlayDef {
1005            shape: OverlayShape::Rectangle {
1006                x: 1,
1007                y: 1,
1008                width: 4,
1009                height: 3,
1010            },
1011            draw_mode: DrawMode::Set,
1012            color: [0, 200, 0],
1013            width_x: 1,
1014            width_y: 1,
1015        }];
1016
1017        let out = draw_overlays(&arr, &overlays);
1018        if let NDDataBuffer::U16(ref v) = out.data {
1019            // Top edge at y=1, x=1
1020            assert_eq!(v[1 * 8 + 1], 200);
1021            assert_eq!(v[1 * 8 + 4], 200);
1022            // Inside should still be 0
1023            assert_eq!(v[2 * 8 + 2], 0);
1024        }
1025    }
1026
1027    #[test]
1028    fn test_f32_overlay_ignores_xor() {
1029        let arr = NDArray::new(
1030            vec![NDDimension::new(8), NDDimension::new(8)],
1031            NDDataType::Float32,
1032        );
1033        let overlays = vec![OverlayDef {
1034            shape: OverlayShape::Cross {
1035                center_x: 4,
1036                center_y: 4,
1037                size: 2,
1038            },
1039            draw_mode: DrawMode::XOR, // should be treated as Set for floats
1040            color: [0, 100, 0],
1041            width_x: 1,
1042            width_y: 1,
1043        }];
1044
1045        let out = draw_overlays(&arr, &overlays);
1046        if let NDDataBuffer::F32(ref v) = out.data {
1047            // Center pixel should be set (XOR falls back to Set for floats)
1048            assert_eq!(v[4 * 8 + 4], 100.0);
1049        }
1050    }
1051
1052    #[test]
1053    fn test_cross_thickness_half_width() {
1054        // C++ Cross uses xwide = WidthX/2: WidthY=4 => horizontal band of
1055        // 2*2+1 = 5 rows centered on the cross.
1056        let arr = NDArray::new(
1057            vec![NDDimension::new(20), NDDimension::new(20)],
1058            NDDataType::UInt8,
1059        );
1060        let overlays = vec![OverlayDef {
1061            shape: OverlayShape::Cross {
1062                center_x: 10,
1063                center_y: 10,
1064                size: 8,
1065            },
1066            draw_mode: DrawMode::Set,
1067            color: [0, 255, 0],
1068            width_x: 1,
1069            width_y: 4,
1070        }];
1071        let out = draw_overlays(&arr, &overlays);
1072        if let NDDataBuffer::U8(ref v) = out.data {
1073            let w = 20;
1074            // The horizontal band spans rows [cy-2, cy+2] = [8, 12]. A column
1075            // away from the vertical strip (e.g. x=7) is set inside the band
1076            // and clear outside.
1077            for y in 8..=12 {
1078                assert_eq!(v[y * w + 7], 255, "row {y} should be in the band");
1079            }
1080            assert_eq!(v[7 * w + 7], 0, "row 7 is outside the band");
1081            assert_eq!(v[13 * w + 7], 0, "row 13 is outside the band");
1082        }
1083    }
1084
1085    #[test]
1086    fn test_xor_ellipse_no_double_toggle() {
1087        // Regression: an XOR ellipse must not leave holes from double-toggled
1088        // pixels. Every drawn pixel ends up XOR'd exactly once: 0 -> 0xFF.
1089        let arr = NDArray::new(
1090            vec![NDDimension::new(40), NDDimension::new(40)],
1091            NDDataType::UInt8,
1092        );
1093        let overlays = vec![OverlayDef {
1094            shape: OverlayShape::Ellipse {
1095                center_x: 20,
1096                center_y: 20,
1097                rx: 12,
1098                ry: 8,
1099            },
1100            draw_mode: DrawMode::XOR,
1101            color: [0, 0xFF, 0],
1102            width_x: 3,
1103            width_y: 3,
1104        }];
1105        let out = draw_overlays(&arr, &overlays);
1106        if let NDDataBuffer::U8(ref v) = out.data {
1107            // Any non-zero pixel must be exactly 0xFF — a double-toggled pixel
1108            // would have wrapped back to 0x00, so the ellipse would have a
1109            // hole. Count drawn pixels to confirm the ellipse is non-empty.
1110            let mut drawn = 0;
1111            for &px in v.iter() {
1112                assert!(px == 0 || px == 0xFF, "double-toggled pixel: {px}");
1113                if px == 0xFF {
1114                    drawn += 1;
1115                }
1116            }
1117            assert!(drawn > 0, "ellipse drew no pixels");
1118        }
1119    }
1120
1121    #[test]
1122    fn test_text_timestamp_format_appends() {
1123        // A non-empty timestamp_format appends a formatted timestamp; an empty
1124        // one renders the bare text. Compare rendered pixel counts.
1125        let mut arr = NDArray::new(
1126            vec![NDDimension::new(120), NDDimension::new(12)],
1127            NDDataType::UInt8,
1128        );
1129        // EPICS timestamp: sec since 1990; pick a value with a known date.
1130        arr.timestamp = ad_core_rs::timestamp::EpicsTimestamp {
1131            sec: 0, // 1990-01-01 00:00:00
1132            nsec: 0,
1133        };
1134        let count_set = |arr: &NDArray, fmt: &str| -> usize {
1135            let ov = vec![OverlayDef {
1136                shape: OverlayShape::Text {
1137                    x: 0,
1138                    y: 0,
1139                    size_x: 120,
1140                    size_y: 12,
1141                    text: "T".to_string(),
1142                    font: 0,
1143                    timestamp_format: fmt.to_string(),
1144                },
1145                draw_mode: DrawMode::Set,
1146                color: [0, 255, 0],
1147                width_x: 1,
1148                width_y: 1,
1149            }];
1150            let out = draw_overlays(arr, &ov);
1151            if let NDDataBuffer::U8(v) = &out.data {
1152                v.iter().filter(|&&p| p != 0).count()
1153            } else {
1154                0
1155            }
1156        };
1157        let bare = count_set(&arr, "");
1158        let with_ts = count_set(&arr, "%Y-%m-%d");
1159        // The appended "1990-01-01" adds glyphs => strictly more set pixels.
1160        assert!(with_ts > bare, "timestamp text should add pixels");
1161    }
1162
1163    // ---- Center/Position freeze semantics (C++ writeInt32) ----------------
1164
1165    use ad_core_rs::plugin::runtime::{ParamChangeValue, ParamUpdate, PluginParamSnapshot};
1166
1167    /// Drive one int32 param change on overlay slot 0 and return the updates.
1168    fn drive(p: &mut OverlayProcessor, reason: usize, value: i32) -> Vec<ParamUpdate> {
1169        let snap = PluginParamSnapshot {
1170            enable_callbacks: true,
1171            reason,
1172            addr: 0,
1173            value: ParamChangeValue::Int32(value),
1174        };
1175        p.on_param_change(reason, &snap).param_updates
1176    }
1177
1178    fn find_int_update(updates: &[ParamUpdate], reason: usize) -> Option<i32> {
1179        updates.iter().find_map(|u| match u {
1180            ParamUpdate::Int32 {
1181                reason: r, value, ..
1182            } if *r == reason => Some(*value),
1183            _ => None,
1184        })
1185    }
1186
1187    fn setup_processor() -> (OverlayProcessor, OverlayParamIndices) {
1188        let mut p = OverlayProcessor::new(vec![]);
1189        let mut base =
1190            asyn_rs::port::PortDriverBase::new("OV_TEST", 8, asyn_rs::port::PortFlags::default());
1191        p.register_params(&mut base).unwrap();
1192        let params = OverlayParamIndices {
1193            position_x: base.find_param("OVERLAY_POSITION_X"),
1194            position_y: base.find_param("OVERLAY_POSITION_Y"),
1195            center_x: base.find_param("OVERLAY_CENTER_X"),
1196            center_y: base.find_param("OVERLAY_CENTER_Y"),
1197            size_x: base.find_param("OVERLAY_SIZE_X"),
1198            size_y: base.find_param("OVERLAY_SIZE_Y"),
1199            ..Default::default()
1200        };
1201        (p, params)
1202    }
1203
1204    #[test]
1205    fn test_freeze_position_then_resize_moves_center() {
1206        // Write PositionX last -> freeze ON. A later SizeX change keeps
1207        // PositionX fixed and moves CenterX (C++ freezePositionX == true).
1208        let (mut p, idx) = setup_processor();
1209        drive(&mut p, idx.size_x.unwrap(), 20);
1210        drive(&mut p, idx.position_x.unwrap(), 100);
1211        assert_eq!(p.slots[0].position_x, 100);
1212        assert_eq!(p.slots[0].center_x, 110); // 100 + 20/2
1213
1214        let updates = drive(&mut p, idx.size_x.unwrap(), 40);
1215        // PositionX stays 100; CenterX moves to 100 + 40/2 = 120.
1216        assert_eq!(p.slots[0].position_x, 100);
1217        assert_eq!(p.slots[0].center_x, 120);
1218        assert_eq!(find_int_update(&updates, idx.center_x.unwrap()), Some(120));
1219    }
1220
1221    #[test]
1222    fn test_freeze_center_then_resize_moves_position() {
1223        // Write CenterX last -> freeze OFF. A later SizeX change keeps
1224        // CenterX fixed and moves PositionX (C++ freezePositionX == false).
1225        let (mut p, idx) = setup_processor();
1226        drive(&mut p, idx.size_x.unwrap(), 20);
1227        drive(&mut p, idx.center_x.unwrap(), 200);
1228        assert_eq!(p.slots[0].center_x, 200);
1229        assert_eq!(p.slots[0].position_x, 190); // 200 - 20/2
1230
1231        let updates = drive(&mut p, idx.size_x.unwrap(), 60);
1232        // CenterX stays 200; PositionX moves to 200 - 60/2 = 170.
1233        assert_eq!(p.slots[0].center_x, 200);
1234        assert_eq!(p.slots[0].position_x, 170);
1235        assert_eq!(
1236            find_int_update(&updates, idx.position_x.unwrap()),
1237            Some(170)
1238        );
1239    }
1240
1241    #[test]
1242    fn test_freeze_y_axis_independent() {
1243        // The Y freeze flag is tracked independently of X.
1244        let (mut p, idx) = setup_processor();
1245        drive(&mut p, idx.size_y.unwrap(), 10);
1246        drive(&mut p, idx.center_y.unwrap(), 50); // freeze_y OFF
1247        drive(&mut p, idx.size_x.unwrap(), 10);
1248        drive(&mut p, idx.position_x.unwrap(), 5); // freeze_x ON
1249        assert!(p.slots[0].freeze_position_x);
1250        assert!(!p.slots[0].freeze_position_y);
1251    }
1252
1253    #[test]
1254    fn test_format_epics_time_known_date() {
1255        // EPICS sec 0 == 1990-01-01 00:00:00 UTC.
1256        let ts = ad_core_rs::timestamp::EpicsTimestamp {
1257            sec: 0,
1258            nsec: 123_456_000,
1259        };
1260        assert_eq!(
1261            format_epics_time(ts, "%Y-%m-%d %H:%M:%S.%f"),
1262            "1990-01-01 00:00:00.123456"
1263        );
1264        assert_eq!(format_epics_time(ts, "100%%"), "100%");
1265    }
1266}