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