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