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